diff --git a/AGENTS.md b/AGENTS.md
index 0519126..204cdc1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,3 +32,13 @@
- Run `bun run test:patch-branches --base "$(git merge-base HEAD origin/main)"` after coverage; changed branch coverage must be at least 90%.
- Confirm Codecov's `patch` status is successful and meets the target configured in `codecov.yml`; do not lower the patch target or threshold to bypass a coverage failure.
- Use the Codecov PR report as the source of truth for patch coverage because it compares the uploaded `lcov.info` against the PR base commit.
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/app/credentials/credential-card.tsx b/app/credentials/credential-card.tsx
index fde8373..7966011 100644
--- a/app/credentials/credential-card.tsx
+++ b/app/credentials/credential-card.tsx
@@ -1,5 +1,5 @@
import { Avatar, Block, Flexbox, Tag } from '@lobehub/ui';
-import { Button } from '@lobehub/ui/base-ui';
+import { Button, Select } from '@lobehub/ui/base-ui';
import {
CalendarDays,
Clock3,
@@ -24,7 +24,7 @@ interface CredentialCardProps {
current: CurrentCredentialInfo | null;
form: CredentialFormState;
onCredentialFirstMessageRoleToSystemChange: (value: boolean) => void;
- onCredentialResponsesPassthroughChange: (value: boolean) => void;
+ onCredentialUpstreamProtocolChange: (value: 'chat' | 'responses') => void;
onDelete: () => void;
onEdit: () => void;
onResetCredentialForm: () => void;
@@ -36,7 +36,7 @@ export const CredentialCard = ({
current,
form,
onCredentialFirstMessageRoleToSystemChange,
- onCredentialResponsesPassthroughChange,
+ onCredentialUpstreamProtocolChange,
onDelete,
onEdit,
onResetCredentialForm,
@@ -104,9 +104,9 @@ export const CredentialCard = ({
- {credential.responses_passthrough
- ? text('credentials.credentialResponsesDirect')
- : text('credentials.credentialResponsesProxyTag')}
+ {credential.upstream_protocol === 'responses'
+ ? text('credentials.credentialUpstreamResponsesTag')
+ : text('credentials.credentialUpstreamChatTag')}
{credential.first_message_role_to_system
@@ -130,12 +130,30 @@ export const CredentialCard = ({
{text('credentials.credentialEditTitle')}
-
+
+ {text('credentials.credentialUpstreamProtocol')}
+
+ onCredentialUpstreamProtocolChange(
+ value === 'responses' ? 'responses' : 'chat',
+ )
+ }
+ options={[
+ {
+ label: text('credentials.credentialUpstreamChat'),
+ value: 'chat',
+ },
+ {
+ label: text('credentials.credentialUpstreamResponses'),
+ value: 'responses',
+ },
+ ]}
+ value={form.upstreamProtocol}
+ />
+ {text('credentials.credentialUpstreamProtocolHelp')}
+
void;
- onCredentialResponsesPassthroughChange: (value: boolean) => void;
+ onCredentialUpstreamProtocolChange: (value: 'chat' | 'responses') => void;
onDelete: (index: number) => void;
onEdit: (credential: CredentialSummary) => void;
onResetCredentialForm: () => void;
@@ -27,7 +27,7 @@ export const CredentialGroup = ({
form,
items,
onCredentialFirstMessageRoleToSystemChange,
- onCredentialResponsesPassthroughChange,
+ onCredentialUpstreamProtocolChange,
onDelete,
onEdit,
onResetCredentialForm,
@@ -47,8 +47,8 @@ export const CredentialGroup = ({
onCredentialFirstMessageRoleToSystemChange={
onCredentialFirstMessageRoleToSystemChange
}
- onCredentialResponsesPassthroughChange={
- onCredentialResponsesPassthroughChange
+ onCredentialUpstreamProtocolChange={
+ onCredentialUpstreamProtocolChange
}
onDelete={() => onDelete(credential.index)}
onEdit={() => onEdit(credential)}
diff --git a/app/credentials/credentials.tsx b/app/credentials/credentials.tsx
index 11688bb..5add03f 100644
--- a/app/credentials/credentials.tsx
+++ b/app/credentials/credentials.tsx
@@ -3,7 +3,7 @@
import { atom } from 'jotai';
import { createContext, useContext, useState } from 'react';
import { Block, Flexbox, Input, TextArea } from '@lobehub/ui';
-import { Button } from '@lobehub/ui/base-ui';
+import { Button, Select } from '@lobehub/ui/base-ui';
import {
Copy,
ExternalLink,
@@ -40,6 +40,7 @@ export interface CredentialSummary {
is_expired: boolean;
name: string | null;
responses_passthrough: boolean;
+ upstream_protocol: 'chat' | 'responses';
scope: string | null;
session_state: string | null;
tenant_id: string | number | null;
@@ -92,7 +93,7 @@ export interface CredentialFormState {
bearerToken: string;
editingIndex: number | null;
firstMessageRoleToSystem: boolean;
- responsesPassthrough: boolean;
+ upstreamProtocol: 'chat' | 'responses';
userId: string;
}
@@ -146,7 +147,7 @@ export const defaultCredentialsState: CredentialsState = {
bearerToken: '',
editingIndex: null,
firstMessageRoleToSystem: false,
- responsesPassthrough: false,
+ upstreamProtocol: 'chat',
userId: '',
},
items: [],
@@ -187,7 +188,7 @@ export interface CredentialsTabController {
onCallbackUrlChange: (value: string) => void;
onCopyAuthUrl: () => void;
onCredentialFirstMessageRoleToSystemChange: (value: boolean) => void;
- onCredentialResponsesPassthroughChange: (value: boolean) => void;
+ onCredentialUpstreamProtocolChange: (value: 'chat' | 'responses') => void;
onCredentialTokenChange: (value: string) => void;
onCredentialUserIdChange: (value: string) => void;
onDeleteCredential: (index: number) => void;
@@ -234,7 +235,7 @@ const Credentials = () => {
onCallbackUrlChange,
onCopyAuthUrl,
onCredentialFirstMessageRoleToSystemChange,
- onCredentialResponsesPassthroughChange,
+ onCredentialUpstreamProtocolChange,
onCredentialTokenChange,
onCredentialUserIdChange,
onDeleteAccessKey,
@@ -452,14 +453,40 @@ const Credentials = () => {
/>
-
+
+
+ {credentialsText('credentials.credentialUpstreamProtocol')}
+
+
+ onCredentialUpstreamProtocolChange(
+ value === 'responses' ? 'responses' : 'chat',
+ )
+ }
+ options={[
+ {
+ label: credentialsText(
+ 'credentials.credentialUpstreamChat',
+ ),
+ value: 'chat',
+ },
+ {
+ label: credentialsText(
+ 'credentials.credentialUpstreamResponses',
+ ),
+ value: 'responses',
+ },
+ ]}
+ value={credentials.form.upstreamProtocol}
+ />
+
+ {credentialsText('credentials.credentialUpstreamProtocolHelp')}
+
+
{
onCredentialFirstMessageRoleToSystemChange={
onCredentialFirstMessageRoleToSystemChange
}
- onCredentialResponsesPassthroughChange={
- onCredentialResponsesPassthroughChange
+ onCredentialUpstreamProtocolChange={
+ onCredentialUpstreamProtocolChange
}
onDelete={onDeleteCredential}
onEdit={onEditCredential}
@@ -608,8 +635,8 @@ const Credentials = () => {
onCredentialFirstMessageRoleToSystemChange={
onCredentialFirstMessageRoleToSystemChange
}
- onCredentialResponsesPassthroughChange={
- onCredentialResponsesPassthroughChange
+ onCredentialUpstreamProtocolChange={
+ onCredentialUpstreamProtocolChange
}
onDelete={onDeleteCredential}
onEdit={onEditCredential}
diff --git a/app/page-shell.tsx b/app/page-shell.tsx
index 1b8eab1..cb8e454 100644
--- a/app/page-shell.tsx
+++ b/app/page-shell.tsx
@@ -984,14 +984,14 @@ const AdminPageLayoutContent = ({
index: credentials.form.editingIndex,
first_message_role_to_system:
credentials.form.firstMessageRoleToSystem,
- responses_passthrough: credentials.form.responsesPassthrough,
+ upstream_protocol: credentials.form.upstreamProtocol,
}
: {
access_token: credentials.form.bearerToken.trim(),
bearer_token: credentials.form.bearerToken.trim(),
first_message_role_to_system:
credentials.form.firstMessageRoleToSystem,
- responses_passthrough: credentials.form.responsesPassthrough,
+ upstream_protocol: credentials.form.upstreamProtocol,
user_id: credentials.form.userId.trim() || undefined,
},
),
@@ -1021,7 +1021,7 @@ const AdminPageLayoutContent = ({
bearerToken: '',
editingIndex: null,
firstMessageRoleToSystem: false,
- responsesPassthrough: false,
+ upstreamProtocol: 'chat',
userId: '',
},
}));
@@ -1736,12 +1736,12 @@ const AdminPageLayoutContent = ({
},
}));
},
- onCredentialResponsesPassthroughChange: (value) => {
+ onCredentialUpstreamProtocolChange: (value) => {
setCredentials((current) => ({
...current,
form: {
...current.form,
- responsesPassthrough: value,
+ upstreamProtocol: value,
},
}));
},
@@ -1774,7 +1774,7 @@ const AdminPageLayoutContent = ({
editingIndex: credential.index,
firstMessageRoleToSystem:
credential.first_message_role_to_system,
- responsesPassthrough: credential.responses_passthrough,
+ upstreamProtocol: credential.upstream_protocol,
userId: credential.user_id ?? '',
},
}));
@@ -1827,7 +1827,7 @@ const AdminPageLayoutContent = ({
bearerToken: '',
editingIndex: null,
firstMessageRoleToSystem: false,
- responsesPassthrough: false,
+ upstreamProtocol: 'chat',
userId: '',
},
}));
diff --git a/bun.lock b/bun.lock
index c046030..f88f904 100644
--- a/bun.lock
+++ b/bun.lock
@@ -31,6 +31,7 @@
"@eslint/compat": "^2.1.0",
"@next/eslint-plugin-next": "^16.3.0",
"@tailwindcss/postcss": "^4.3.3",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.0",
"@types/better-sqlite3": "^9.6.0",
diff --git a/lib/server/domain/credentials.ts b/lib/server/domain/credentials.ts
index 2192fe4..1179380 100644
--- a/lib/server/domain/credentials.ts
+++ b/lib/server/domain/credentials.ts
@@ -30,6 +30,7 @@ export type CredentialData = Record & {
user_id?: string;
user_info?: Record;
responses_passthrough?: boolean;
+ upstream_protocol?: 'chat' | 'responses';
first_message_role_to_system?: boolean;
supported_models?: string;
};
@@ -473,6 +474,8 @@ export const listCredentials = async (): Promise<{
responses_passthrough: getBooleanSetting(
record.data.responses_passthrough,
),
+ upstream_protocol: getCredentialProxySettings(record.data)
+ .upstreamProtocol,
first_message_role_to_system: getBooleanSetting(
record.data.first_message_role_to_system,
),
@@ -559,6 +562,21 @@ export const addCredential = async (
existingPayload = storedPayload;
}
+ const upstreamProtocol =
+ credentialData.upstream_protocol === 'responses'
+ ? 'responses'
+ : credentialData.upstream_protocol === 'chat'
+ ? 'chat'
+ : credentialData.responses_passthrough !== undefined
+ ? getBooleanSetting(credentialData.responses_passthrough)
+ ? 'responses'
+ : 'chat'
+ : existingPayload.upstream_protocol === 'responses'
+ ? 'responses'
+ : getBooleanSetting(existingPayload.responses_passthrough)
+ ? 'responses'
+ : 'chat';
+
const payload = {
...existingPayload,
...credentialData,
@@ -566,10 +584,8 @@ export const addCredential = async (
typeof existingPayload.created_at === 'number'
? existingPayload.created_at
: now,
- responses_passthrough: getBooleanSetting(
- credentialData.responses_passthrough ??
- existingPayload.responses_passthrough,
- ),
+ responses_passthrough: upstreamProtocol === 'responses',
+ upstream_protocol: upstreamProtocol,
first_message_role_to_system: getBooleanSetting(
credentialData.first_message_role_to_system ??
existingPayload.first_message_role_to_system,
@@ -779,12 +795,17 @@ export const getCredentialProxySettings = (
credential: CredentialData | null | undefined,
): {
firstMessageRoleToSystem: boolean;
- responsesPassthrough: boolean;
+ upstreamProtocol: 'chat' | 'responses';
} => {
return {
firstMessageRoleToSystem: getBooleanSetting(
credential?.first_message_role_to_system,
),
- responsesPassthrough: getBooleanSetting(credential?.responses_passthrough),
+ upstreamProtocol:
+ credential?.upstream_protocol === 'responses' ||
+ (credential?.upstream_protocol !== 'chat' &&
+ getBooleanSetting(credential?.responses_passthrough))
+ ? 'responses'
+ : 'chat',
};
};
diff --git a/lib/server/domain/usage.ts b/lib/server/domain/usage.ts
index 75fac45..d3a6c9b 100644
--- a/lib/server/domain/usage.ts
+++ b/lib/server/domain/usage.ts
@@ -22,6 +22,10 @@ export type UsageRange =
export interface UsageSnapshot {
cache_creation_input_tokens?: number | null;
cache_read_input_tokens?: number | null;
+ input_tokens_details?: {
+ cached_tokens?: number | null;
+ cache_creation_tokens?: number | null;
+ } | null;
prompt_cache_hit_tokens?: number | null;
prompt_cache_miss_tokens?: number | null;
prompt_cache_write_tokens?: number | null;
@@ -155,14 +159,17 @@ const getLargestTokenCount = (...values: unknown[]): number => {
const normalizeUsage = (usage: UsageSnapshot): UsageEventRecord => {
const inputTokens = toNumber(usage.input_tokens ?? usage.prompt_tokens);
const outputTokens = toNumber(usage.output_tokens ?? usage.completion_tokens);
+ const inputTokenDetails = usage.input_tokens_details;
const promptTokenDetails = usage.prompt_tokens_details;
const cacheReadTokens = getLargestTokenCount(
usage.cache_read_input_tokens,
+ inputTokenDetails?.cached_tokens,
promptTokenDetails?.cached_tokens,
usage.prompt_cache_hit_tokens,
);
const cacheCreationTokens = getLargestTokenCount(
usage.cache_creation_input_tokens,
+ inputTokenDetails?.cache_creation_tokens,
promptTokenDetails?.cache_creation_tokens,
usage.prompt_cache_write_tokens,
);
@@ -171,7 +178,9 @@ const normalizeUsage = (usage: UsageSnapshot): UsageEventRecord => {
explicitTotal ||
inputTokens +
outputTokens +
- (promptTokenDetails ? 0 : cacheReadTokens + cacheCreationTokens);
+ (inputTokenDetails || promptTokenDetails
+ ? 0
+ : cacheReadTokens + cacheCreationTokens);
return {
accessKeyId: null,
diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts
index bea0c15..be28486 100644
--- a/lib/server/proxy/anthropic.ts
+++ b/lib/server/proxy/anthropic.ts
@@ -392,6 +392,11 @@ const buildChatRequestBody = async (
const chatMessages = mapAnthropicMessagesToChat(body.messages ?? []);
const messages: ChatMessage[] = [];
+ const disableParallelToolUse =
+ body.tool_choice && typeof body.tool_choice === 'object'
+ ? (body.tool_choice as { disable_parallel_tool_use?: unknown })
+ .disable_parallel_tool_use
+ : undefined;
if (systemText) {
messages.push({ role: 'system', content: systemText });
@@ -412,6 +417,10 @@ const buildChatRequestBody = async (
stop: body.stop_sequences,
tools: mapAnthropicToolsToChat(body.tools),
tool_choice: mapAnthropicToolChoiceToChat(body.tool_choice),
+ parallel_tool_calls:
+ typeof disableParallelToolUse === 'boolean'
+ ? !disableParallelToolUse
+ : undefined,
};
// Pass through thinking/reasoning config so upstream models that support
@@ -832,13 +841,15 @@ const mapOpenAIStreamToAnthropicSSE = (
const upstreamReader = upstreamResponse.body!.getReader();
reader = upstreamReader;
let buffer = '';
- const rejectStream = (): void => {
+ const rejectStream = (
+ message = 'Upstream SSE frame exceeds the maximum size',
+ ): void => {
streamRejected = true;
enqueueEvent({
type: 'error',
error: {
type: 'invalid_request_error',
- message: 'Upstream SSE frame exceeds the maximum size',
+ message,
},
});
};
@@ -867,7 +878,7 @@ const mapOpenAIStreamToAnthropicSSE = (
const chunk = JSON.parse(raw) as OpenAIStreamChunk;
const upstreamError = chunk as OpenAIStreamError;
if (upstreamError.error?.message) {
- rejectStream();
+ rejectStream(upstreamError.error.message);
return;
}
processChunk(chunk);
@@ -939,6 +950,38 @@ const mapOpenAIStreamToAnthropicSSE = (
});
};
+const extractErrorMessage = (value: unknown): string | null => {
+ if (typeof value === 'string') {
+ try {
+ return extractErrorMessage(JSON.parse(value) as unknown) ?? value;
+ } catch {
+ return value;
+ }
+ }
+ if (!value || typeof value !== 'object') return null;
+
+ const payload = value as {
+ detail?: unknown;
+ error?: unknown;
+ message?: unknown;
+ };
+ const detail = extractErrorMessage(payload.detail);
+ if (detail) return detail;
+ if (typeof payload.message === 'string') return payload.message;
+ return extractErrorMessage(payload.error);
+};
+
+const getUpstreamErrorMessage = async (response: Response): Promise => {
+ const text = await response.text();
+ if (!text) return 'Upstream CodeBuddy request failed';
+
+ try {
+ return extractErrorMessage(JSON.parse(text) as unknown) ?? text;
+ } catch {
+ return text;
+ }
+};
+
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
@@ -963,7 +1006,10 @@ export const handleMessagesRequest = async (
);
if (!upstreamResponse.ok) {
- return upstreamResponse;
+ return createAnthropicError(
+ upstreamResponse.status,
+ await getUpstreamErrorMessage(upstreamResponse),
+ );
}
const model = String(chatBody.model ?? 'unknown');
@@ -984,11 +1030,28 @@ export const handleMessagesRequest = async (
};
const createAnthropicError = (status: number, message: string): Response => {
+ const type =
+ status === 401
+ ? 'authentication_error'
+ : status === 403
+ ? 'permission_error'
+ : status === 404
+ ? 'not_found_error'
+ : status === 413
+ ? 'request_too_large'
+ : status === 429
+ ? 'rate_limit_error'
+ : status === 529
+ ? 'overloaded_error'
+ : status >= 500
+ ? 'api_error'
+ : 'invalid_request_error';
+
return Response.json(
{
type: 'error',
error: {
- type: 'invalid_request_error',
+ type,
message,
},
},
diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts
index 460508a..0408657 100644
--- a/lib/server/proxy/codebuddy.ts
+++ b/lib/server/proxy/codebuddy.ts
@@ -42,6 +42,9 @@ export interface ChatRequestBody {
model?: string;
messages?: OpenAIMessage[];
stream?: boolean;
+ stream_options?: {
+ include_usage?: boolean;
+ };
temperature?: number;
max_tokens?: number;
max_completion_tokens?: number;
@@ -52,6 +55,7 @@ export interface ChatRequestBody {
stop?: string | string[];
tools?: unknown[];
tool_choice?: unknown;
+ parallel_tool_calls?: boolean;
thinking?: Record;
reasoning_effort?: string;
}
@@ -111,7 +115,7 @@ export interface ProxyContext {
credentialFilename: string | null;
preferences: {
firstMessageRoleToSystem: boolean;
- responsesPassthrough: boolean;
+ upstreamProtocol: 'chat' | 'responses';
};
}
@@ -182,6 +186,65 @@ const extractResponsesUsage = (value: unknown): unknown => {
return payload.response?.usage ?? payload.usage ?? null;
};
+const mapResponsesUsageToChat = (
+ usage: unknown,
+): Record | null => {
+ if (!usage || typeof usage !== 'object') return null;
+
+ const value = usage as {
+ cache_creation_input_tokens?: unknown;
+ cache_read_input_tokens?: unknown;
+ input_tokens?: unknown;
+ input_tokens_details?: {
+ cache_creation_tokens?: unknown;
+ cached_tokens?: unknown;
+ };
+ output_tokens?: unknown;
+ output_tokens_details?: {
+ reasoning_tokens?: unknown;
+ };
+ total_tokens?: unknown;
+ };
+ const inputTokens = Number(value.input_tokens ?? 0);
+ const outputTokens = Number(value.output_tokens ?? 0);
+ const cachedTokens = Number(
+ value.input_tokens_details?.cached_tokens ??
+ value.cache_read_input_tokens ??
+ 0,
+ );
+ const cacheCreationTokens = Number(
+ value.input_tokens_details?.cache_creation_tokens ??
+ value.cache_creation_input_tokens ??
+ 0,
+ );
+ const reasoningTokens = Number(
+ value.output_tokens_details?.reasoning_tokens ?? 0,
+ );
+
+ return {
+ completion_tokens: outputTokens,
+ completion_tokens_details: {
+ reasoning_tokens: reasoningTokens,
+ },
+ prompt_tokens: inputTokens,
+ prompt_tokens_details: {
+ cache_creation_tokens: cacheCreationTokens,
+ cached_tokens: cachedTokens,
+ },
+ total_tokens: Number(value.total_tokens ?? inputTokens + outputTokens),
+ };
+};
+
+const extractResponsesId = (value: unknown): string | null => {
+ if (!value || typeof value !== 'object') return null;
+ const payload = value as {
+ id?: unknown;
+ response?: { id?: unknown };
+ };
+ const id = payload.response?.id ?? payload.id;
+ return typeof id === 'string' && id ? id : null;
+};
+
const parseUsageHeader = (response: Response): unknown => {
const usageHeader = response.headers.get('x-codebuddy-usage');
@@ -199,11 +262,13 @@ const parseUsageHeader = (response: Response): unknown => {
const trackResponsesUsageStream = async ({
fallbackUsage,
model,
+ onResponseId,
proxyContext,
upstreamResponse,
}: {
fallbackUsage: unknown;
model: string;
+ onResponseId?: (responseId: string) => Promise;
proxyContext: ProxyContext;
upstreamResponse: Response;
}): Promise => {
@@ -225,36 +290,71 @@ const trackResponsesUsageStream = async ({
const encoder = new TextEncoder();
let reader: ReadableStreamDefaultReader | null = null;
let cancelled = false;
+ let latestUsage = fallbackUsage;
+ let responseBinding: Promise | null = null;
+ let usageRecorded = false;
const releaseReader = (): void => {
reader?.releaseLock();
reader = null;
};
+ const recordStreamUsage = async (): Promise => {
+ if (usageRecorded) return;
+ usageRecorded = true;
+ try {
+ await recordProxyUsage({
+ model,
+ proxyContext,
+ route: '/v1/responses',
+ usage: latestUsage,
+ });
+ } catch (error) {
+ console.error('[CodeBuddy2API] Failed to record Responses stream usage', {
+ error,
+ route: '/v1/responses',
+ });
+ }
+ };
+ const bindResponseId = (id: string): Promise => {
+ if (!onResponseId) return Promise.resolve();
+ responseBinding ??= onResponseId(id).catch((error) => {
+ console.error(
+ '[CodeBuddy2API] Failed to bind upstream Responses session',
+ {
+ error,
+ responseId: id,
+ },
+ );
+ });
+ return responseBinding;
+ };
const stream = new ReadableStream({
start: (controller) => {
const upstreamReader = upstreamResponse.body!.getReader();
reader = upstreamReader;
let buffer = '';
- let latestUsage = fallbackUsage;
+ let responseId: string | null = null;
- const inspectFrame = (frame: string): void => {
- frame.split('\n').forEach((line) => {
+ const inspectFrame = async (frame: string): Promise => {
+ for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) {
- return;
+ continue;
}
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') {
- return;
+ continue;
}
try {
- latestUsage =
- extractResponsesUsage(JSON.parse(raw) as unknown) ?? latestUsage;
+ const event = JSON.parse(raw) as unknown;
+ latestUsage = extractResponsesUsage(event) ?? latestUsage;
+ responseId = extractResponsesId(event) ?? responseId;
+ if (responseId) await bindResponseId(responseId);
} catch {
// Preserve malformed upstream frames without recording them.
}
- });
+ }
};
const pump = async (): Promise => {
@@ -267,16 +367,12 @@ const trackResponsesUsageStream = async ({
if (done) {
if (buffer) {
- inspectFrame(buffer);
+ await inspectFrame(buffer);
controller.enqueue(encoder.encode(buffer));
}
- await recordProxyUsage({
- model,
- proxyContext,
- route: '/v1/responses',
- usage: latestUsage,
- });
+ await recordStreamUsage();
+ await responseBinding;
releaseReader();
controller.close();
return;
@@ -290,23 +386,36 @@ const trackResponsesUsageStream = async ({
buffer = '';
}
- frames.forEach((frame) => {
+ for (const frame of frames) {
if (frame.length > MAX_STREAM_FRAME_LENGTH) {
- return;
+ continue;
}
- inspectFrame(frame);
+ await inspectFrame(frame);
+ if (cancelled) return;
controller.enqueue(encoder.encode(`${frame}\n\n`));
- });
+ }
}
};
- void pump();
+ void pump().catch(async (error) => {
+ if (cancelled) return;
+ console.error('[CodeBuddy2API] Responses upstream stream failed', {
+ error,
+ route: '/v1/responses',
+ });
+ await responseBinding;
+ await recordStreamUsage();
+ releaseReader();
+ controller.error(error);
+ });
},
async cancel(reason): Promise {
cancelled = true;
try {
await reader?.cancel(reason);
} finally {
+ await responseBinding;
+ await recordStreamUsage();
releaseReader();
}
},
@@ -707,13 +816,932 @@ const buildUpstreamBody = async (
frequency_penalty: body.frequency_penalty,
presence_penalty: body.presence_penalty,
stop: body.stop,
+ stream_options: body.stream_options,
tools: body.tools,
tool_choice: body.tool_choice,
+ parallel_tool_calls: body.parallel_tool_calls,
thinking: body.thinking,
reasoning_effort: body.reasoning_effort,
};
};
+const stringifyResponsesInputContent = (content: unknown): string => {
+ if (typeof content === 'string') return content;
+ if (content === null || content === undefined) return '';
+ if (Array.isArray(content)) {
+ return content
+ .map((part) => {
+ if (typeof part === 'string') return part;
+ if (part && typeof part === 'object' && 'text' in part) {
+ return String((part as { text?: unknown }).text ?? '');
+ }
+ return JSON.stringify(part);
+ })
+ .join('');
+ }
+ return JSON.stringify(content);
+};
+
+const mapChatContentToResponses = (
+ content: unknown,
+): Array> => {
+ if (!Array.isArray(content)) {
+ return [
+ {
+ text: stringifyResponsesInputContent(content),
+ type: 'input_text',
+ },
+ ];
+ }
+
+ return content.flatMap((part): Array> => {
+ if (typeof part === 'string') {
+ return [{ text: part, type: 'input_text' }];
+ }
+ if (!part || typeof part !== 'object') {
+ return [{ text: JSON.stringify(part), type: 'input_text' }];
+ }
+ const value = part as {
+ image_url?: string | { detail?: unknown; url?: unknown };
+ text?: unknown;
+ type?: unknown;
+ };
+ if (value.type === 'image_url') {
+ const imageUrl =
+ typeof value.image_url === 'string'
+ ? value.image_url
+ : value.image_url?.url;
+ if (typeof imageUrl === 'string' && imageUrl) {
+ const detail =
+ typeof value.image_url === 'object' &&
+ typeof value.image_url.detail === 'string'
+ ? value.image_url.detail
+ : undefined;
+ return [
+ {
+ image_url: imageUrl,
+ ...(detail ? { detail } : {}),
+ type: 'input_image',
+ },
+ ];
+ }
+ }
+ if (value.type === 'input_image' && typeof value.image_url === 'string') {
+ return [{ image_url: value.image_url, type: 'input_image' }];
+ }
+ if (typeof value.text === 'string') {
+ return [{ text: value.text, type: 'input_text' }];
+ }
+ return [{ text: JSON.stringify(value), type: 'input_text' }];
+ });
+};
+
+const translateChatToolChoiceToResponses = (toolChoice: unknown): unknown => {
+ if (typeof toolChoice === 'string') return toolChoice;
+ if (!toolChoice || typeof toolChoice !== 'object') return undefined;
+ const value = toolChoice as {
+ function?: { name?: unknown };
+ name?: unknown;
+ type?: unknown;
+ };
+ if (value.type !== 'function') return toolChoice;
+ const name = value.function?.name ?? value.name;
+ return typeof name === 'string' ? { name, type: 'function' } : toolChoice;
+};
+
+const translateChatResponseFormatToResponses = (
+ responseFormat: unknown,
+): Record | undefined => {
+ if (!responseFormat || typeof responseFormat !== 'object') return undefined;
+ const value = responseFormat as {
+ json_schema?: Record;
+ type?: unknown;
+ };
+ if (value.type === 'json_object') {
+ return { format: { type: 'json_object' } };
+ }
+ if (value.type !== 'json_schema' || !value.json_schema) return undefined;
+ const schema = value.json_schema;
+ if (typeof schema.name !== 'string' || !schema.name) return undefined;
+ return {
+ format: {
+ ...(schema.description ? { description: schema.description } : {}),
+ name: schema.name,
+ schema: schema.schema ?? { type: 'object', properties: {} },
+ ...(typeof schema.strict === 'boolean' ? { strict: schema.strict } : {}),
+ type: 'json_schema',
+ },
+ };
+};
+
+const translateChatThinkingToResponses = (
+ thinking: Record | undefined,
+ reasoningEffort: string | undefined,
+): Record | undefined => {
+ if (!thinking)
+ return reasoningEffort ? { effort: reasoningEffort } : undefined;
+
+ if (thinking.type === 'disabled') return { effort: 'none' };
+ if (thinking.type !== 'adaptive' && thinking.type !== 'enabled') {
+ return undefined;
+ }
+
+ const budgetTokens =
+ typeof thinking.budget_tokens === 'number'
+ ? thinking.budget_tokens
+ : Number.NaN;
+ const effort = reasoningEffort
+ ? reasoningEffort
+ : Number.isFinite(budgetTokens)
+ ? budgetTokens <= 2_048
+ ? 'low'
+ : budgetTokens <= 8_192
+ ? 'medium'
+ : 'high'
+ : undefined;
+
+ return {
+ ...(effort ? { effort } : {}),
+ summary: 'auto',
+ };
+};
+
+const normalizeStopSequences = (
+ stop: string | string[] | undefined,
+): string[] => {
+ return (Array.isArray(stop) ? stop : stop ? [stop] : []).filter(Boolean);
+};
+
+const findFirstStopSequence = (
+ text: string,
+ stopSequences: string[],
+): number | null => {
+ return stopSequences.reduce((earliest, stopSequence) => {
+ const index = text.indexOf(stopSequence);
+ if (index < 0) return earliest;
+ return earliest === null ? index : Math.min(earliest, index);
+ }, null);
+};
+
+const getPendingStopPrefixLength = (
+ text: string,
+ stopSequences: string[],
+): number => {
+ const maximumLength = Math.min(
+ text.length,
+ Math.max(
+ 0,
+ ...stopSequences.map((stopSequence) => stopSequence.length - 1),
+ ),
+ );
+
+ for (let length = maximumLength; length > 0; length -= 1) {
+ const suffix = text.slice(-length);
+ if (stopSequences.some((stopSequence) => stopSequence.startsWith(suffix))) {
+ return length;
+ }
+ }
+
+ return 0;
+};
+
+const normalizeResponsesUpstreamBody = (
+ body: Record,
+): Record => {
+ const { messages, ...rest } = body;
+
+ if (rest.input !== undefined || !Array.isArray(messages)) {
+ return rest;
+ }
+
+ const systemInstructions = messages
+ .filter((message) => {
+ return (
+ message &&
+ typeof message === 'object' &&
+ ((message as { role?: unknown }).role === 'system' ||
+ (message as { role?: unknown }).role === 'developer')
+ );
+ })
+ .map((message) => {
+ return stringifyResponsesInputContent(
+ (message as { content?: unknown }).content,
+ );
+ })
+ .filter(Boolean)
+ .join('\n\n');
+ const input = messages.flatMap((message) => {
+ if (!message || typeof message !== 'object') return [];
+ const value = message as { content?: unknown; role?: unknown };
+ if (value.role === 'system' || value.role === 'developer') return [];
+ const role = value.role === 'assistant' ? 'assistant' : 'user';
+ return [
+ {
+ content: mapChatContentToResponses(value.content),
+ role,
+ },
+ ];
+ });
+
+ const existingInstructions =
+ typeof rest.instructions === 'string' ? rest.instructions.trim() : '';
+ const instructions = [existingInstructions, systemInstructions]
+ .filter(Boolean)
+ .join('\n\n');
+
+ return { ...rest, ...(instructions ? { instructions } : {}), input };
+};
+
+const buildResponsesBodyFromChat = (
+ body: ChatRequestBody,
+): Record => {
+ const instructions = body.messages
+ ?.filter(
+ (message) => message.role === 'system' || message.role === 'developer',
+ )
+ .map((message) => stringifyResponsesInputContent(message.content))
+ .filter(Boolean)
+ .join('\n\n');
+ const input =
+ body.messages
+ ?.filter(
+ (message) => message.role !== 'system' && message.role !== 'developer',
+ )
+ .map((message) => {
+ if (message.role === 'tool') {
+ return {
+ call_id: message.tool_call_id,
+ output: stringifyResponsesInputContent(message.content),
+ type: 'function_call_output',
+ };
+ }
+ const toolCalls = Array.isArray(message.tool_calls)
+ ? message.tool_calls
+ : [];
+ const functionCalls = toolCalls.flatMap((toolCall) => {
+ if (!toolCall || typeof toolCall !== 'object') return [];
+ const call = toolCall as {
+ function?: { arguments?: unknown; name?: unknown };
+ id?: unknown;
+ };
+ if (typeof call.function?.name !== 'string') return [];
+ return [
+ {
+ arguments: String(call.function.arguments ?? ''),
+ call_id: String(call.id ?? crypto.randomUUID()),
+ name: call.function.name,
+ type: 'function_call',
+ },
+ ];
+ });
+ const content = mapChatContentToResponses(message.content);
+ const hasContent = content.some((part) => {
+ return (
+ (part.type === 'input_text' && Boolean(part.text)) ||
+ (part.type === 'input_image' && Boolean(part.image_url))
+ );
+ });
+ const shouldOmitMessage =
+ message.role === 'assistant' &&
+ functionCalls.length > 0 &&
+ !hasContent;
+
+ return [
+ ...(shouldOmitMessage
+ ? []
+ : [
+ {
+ content,
+ role: message.role === 'assistant' ? 'assistant' : 'user',
+ },
+ ]),
+ ...functionCalls,
+ ];
+ })
+ .flat() ?? [];
+ const tools = body.tools?.flatMap((tool) => {
+ if (!tool || typeof tool !== 'object') return [];
+ const value = tool as {
+ function?: Record;
+ type?: unknown;
+ };
+ const definition: Record =
+ value.type === 'function' && value.function ? value.function : value;
+ if (typeof definition.name !== 'string') return [];
+ return [
+ {
+ ...definition,
+ parameters: definition.parameters ?? { type: 'object', properties: {} },
+ type: 'function',
+ },
+ ];
+ });
+ const text = translateChatResponseFormatToResponses(body.response_format);
+ const reasoning = translateChatThinkingToResponses(
+ body.thinking,
+ body.reasoning_effort,
+ );
+
+ return {
+ ...(instructions ? { instructions } : {}),
+ input,
+ max_output_tokens: body.max_tokens ?? body.max_completion_tokens,
+ model: body.model,
+ parallel_tool_calls: body.parallel_tool_calls,
+ reasoning,
+ stream: Boolean(body.stream),
+ temperature: body.temperature,
+ top_p: body.top_p,
+ ...(tools?.length ? { tools } : {}),
+ ...(body.tool_choice
+ ? { tool_choice: translateChatToolChoiceToResponses(body.tool_choice) }
+ : {}),
+ ...(text ? { text } : {}),
+ };
+};
+
+const getUnsupportedResponsesChatOptions = (
+ body: ChatRequestBody,
+): string[] => {
+ return [
+ body.frequency_penalty !== undefined ? 'frequency_penalty' : null,
+ body.presence_penalty !== undefined ? 'presence_penalty' : null,
+ body.thinking !== undefined &&
+ !translateChatThinkingToResponses(body.thinking, body.reasoning_effort)
+ ? 'thinking'
+ : null,
+ ].filter((name): name is string => Boolean(name));
+};
+
+const extractResponsesReasoningText = (output: unknown[]): string => {
+ return output
+ .flatMap((item) => {
+ if (!item || typeof item !== 'object') return [];
+ const value = item as {
+ content?: unknown;
+ summary?: unknown;
+ type?: unknown;
+ };
+ if (value.type !== 'reasoning') return [];
+ return [value.summary, value.content].flatMap((parts) => {
+ if (!Array.isArray(parts)) return [];
+ return parts.flatMap((part) => {
+ if (!part || typeof part !== 'object') return [];
+ const text = (part as { text?: unknown }).text;
+ return typeof text === 'string' ? [text] : [];
+ });
+ });
+ })
+ .join('');
+};
+
+const mapResponsesPayloadToChat = (
+ payload: Record,
+ model: string,
+ stop: string | string[] | undefined,
+): Record => {
+ const output = Array.isArray(payload.output) ? payload.output : [];
+ const toolCalls = output.flatMap((item) => {
+ if (!item || typeof item !== 'object') return [];
+ const value = item as Record;
+ if (value.type !== 'function_call') return [];
+ return [
+ {
+ function: {
+ arguments: String(value.arguments ?? ''),
+ name: String(value.name ?? 'function'),
+ },
+ id: String(value.call_id ?? value.id ?? crypto.randomUUID()),
+ type: 'function',
+ },
+ ];
+ });
+ const usage =
+ payload.usage && typeof payload.usage === 'object'
+ ? (payload.usage as Record)
+ : undefined;
+ const inputTokens = Number(usage?.input_tokens ?? 0);
+ const outputTokens = Number(usage?.output_tokens ?? 0);
+
+ const rawOutputText =
+ typeof payload.output_text === 'string'
+ ? payload.output_text
+ : output
+ .flatMap((item) => {
+ if (!item || typeof item !== 'object') return [];
+ const content = (item as { content?: unknown }).content;
+ if (!Array.isArray(content)) return [];
+ return content.flatMap((part) => {
+ if (!part || typeof part !== 'object') return [];
+ const value = part as { text?: unknown; type?: unknown };
+ return value.type === 'output_text' &&
+ typeof value.text === 'string'
+ ? [value.text]
+ : [];
+ });
+ })
+ .join('');
+ const stopIndex = findFirstStopSequence(
+ rawOutputText,
+ normalizeStopSequences(stop),
+ );
+ const outputText =
+ stopIndex === null ? rawOutputText : rawOutputText.slice(0, stopIndex);
+ const reasoningText = extractResponsesReasoningText(output);
+ const incompleteReason =
+ payload.incomplete_details && typeof payload.incomplete_details === 'object'
+ ? (payload.incomplete_details as { reason?: unknown }).reason
+ : undefined;
+ const finishReason =
+ payload.status === 'incomplete'
+ ? incompleteReason === 'content_filter'
+ ? 'content_filter'
+ : 'length'
+ : toolCalls.length
+ ? 'tool_calls'
+ : 'stop';
+
+ return {
+ choices: [
+ {
+ finish_reason: finishReason,
+ index: 0,
+ message: {
+ content: outputText || null,
+ role: 'assistant',
+ ...(reasoningText ? { reasoning_content: reasoningText } : {}),
+ ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
+ },
+ },
+ ],
+ created: Number(payload.created_at ?? Math.floor(Date.now() / 1000)),
+ id: String(payload.id ?? `chatcmpl-${crypto.randomUUID()}`),
+ model,
+ object: 'chat.completion',
+ usage: {
+ completion_tokens: outputTokens,
+ prompt_tokens: inputTokens,
+ total_tokens: Number(usage?.total_tokens ?? inputTokens + outputTokens),
+ },
+ };
+};
+
+const mapResponsesStreamToChat = (
+ upstreamResponse: Response,
+ model: string,
+ proxyContext: ProxyContext,
+ route: string,
+ stop: string | string[] | undefined,
+ includeUsage: boolean,
+): Response => {
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder();
+ const responseId = `chatcmpl-${crypto.randomUUID()}`;
+ let reader: ReadableStreamDefaultReader | null =
+ upstreamResponse.body?.getReader() ?? null;
+ const fallbackUsage = parseUsageHeader(upstreamResponse);
+ let buffer = '';
+ let emittedFinish = false;
+ let emittedUsage = false;
+ let hasToolCalls = false;
+ let latestUsage = fallbackUsage;
+ let usageRecorded = false;
+ const stopSequences = normalizeStopSequences(stop);
+ let pendingStopText = '';
+ const toolIndexes = new Map();
+ const toolCallIds = new Map();
+ let nextToolIndex = 0;
+ let stoppedLocally = false;
+
+ const getToolIndex = (itemId: string): number => {
+ const existing = toolIndexes.get(itemId);
+ if (existing !== undefined) return existing;
+ const index = nextToolIndex;
+ nextToolIndex += 1;
+ toolIndexes.set(itemId, index);
+ return index;
+ };
+
+ const encodeChunk = (choice: Record): Uint8Array => {
+ return encoder.encode(
+ `data: ${JSON.stringify({
+ choices: [choice],
+ created: Math.floor(Date.now() / 1000),
+ id: responseId,
+ model,
+ object: 'chat.completion.chunk',
+ })}\n\n`,
+ );
+ };
+
+ const enqueueUsage = (
+ controller: ReadableStreamDefaultController,
+ ): void => {
+ if (!includeUsage || emittedUsage) return;
+ const usage = mapResponsesUsageToChat(latestUsage);
+ if (!usage) return;
+
+ emittedUsage = true;
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({
+ choices: [],
+ created: Math.floor(Date.now() / 1000),
+ id: responseId,
+ model,
+ object: 'chat.completion.chunk',
+ usage,
+ })}\n\n`,
+ ),
+ );
+ };
+
+ const recordStreamUsage = async (): Promise => {
+ if (usageRecorded) return;
+ usageRecorded = true;
+ try {
+ await recordProxyUsage({
+ model,
+ proxyContext,
+ route,
+ usage: latestUsage,
+ });
+ } catch (error) {
+ console.error('[CodeBuddy2API] Failed to record Responses stream usage', {
+ error,
+ route,
+ });
+ }
+ };
+
+ const cancelAndReleaseReader = async (reason?: unknown): Promise => {
+ try {
+ await reader?.cancel(reason);
+ } catch (error) {
+ console.error('[CodeBuddy2API] Failed to cancel Responses stream', {
+ error,
+ route,
+ });
+ } finally {
+ reader?.releaseLock();
+ reader = null;
+ }
+ };
+
+ const stream = new ReadableStream({
+ async pull(controller) {
+ if (!reader) {
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ while (true) {
+ let readResult: ReadableStreamReadResult;
+ try {
+ readResult = await reader.read();
+ } catch (error) {
+ await recordStreamUsage();
+ reader.releaseLock();
+ reader = null;
+ controller.error(error);
+ return;
+ }
+ const { done, value } = readResult;
+ if (done) {
+ if (pendingStopText) {
+ controller.enqueue(
+ encodeChunk({
+ delta: { content: pendingStopText },
+ index: 0,
+ }),
+ );
+ pendingStopText = '';
+ }
+ if (!emittedFinish) {
+ controller.enqueue(
+ encodeChunk({
+ delta: {},
+ finish_reason: hasToolCalls ? 'tool_calls' : 'stop',
+ index: 0,
+ }),
+ );
+ }
+ enqueueUsage(controller);
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await recordStreamUsage();
+ reader.releaseLock();
+ reader = null;
+ controller.close();
+ return;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ const frames = buffer.split(/\r?\n\r?\n/);
+ buffer = frames.pop() ?? '';
+ if (buffer.length > MAX_STREAM_FRAME_LENGTH) {
+ controller.enqueue(
+ encoder.encode(
+ 'data: {"error":{"message":"Upstream SSE frame exceeds the maximum size"}}\n\n',
+ ),
+ );
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await cancelAndReleaseReader();
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ let emitted = false;
+ for (const frame of frames) {
+ if (frame.length > MAX_STREAM_FRAME_LENGTH) {
+ controller.enqueue(
+ encoder.encode(
+ 'data: {"error":{"message":"Upstream SSE frame exceeds the maximum size"}}\n\n',
+ ),
+ );
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await cancelAndReleaseReader();
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ const dataLine = frame
+ .split(/\r?\n/)
+ .find((line) => line.startsWith('data: '));
+ if (!dataLine || dataLine === 'data: [DONE]') continue;
+ try {
+ const event = JSON.parse(dataLine.slice(6)) as {
+ delta?: unknown;
+ item?: unknown;
+ item_id?: unknown;
+ output_index?: unknown;
+ error?: unknown;
+ response?: unknown;
+ type?: unknown;
+ };
+ latestUsage = extractResponsesUsage(event) ?? latestUsage;
+ if (
+ stoppedLocally &&
+ event.type !== 'response.completed' &&
+ event.type !== 'response.incomplete'
+ ) {
+ continue;
+ }
+ if (event.type === 'response.output_text.delta') {
+ const delta = String(event.delta ?? '');
+ if (stopSequences.length) {
+ pendingStopText += delta;
+ const stopIndex = findFirstStopSequence(
+ pendingStopText,
+ stopSequences,
+ );
+ if (stopIndex !== null) {
+ const content = pendingStopText.slice(0, stopIndex);
+ if (content) {
+ controller.enqueue(
+ encodeChunk({ delta: { content }, index: 0 }),
+ );
+ }
+ pendingStopText = '';
+ controller.enqueue(
+ encodeChunk({
+ delta: {},
+ finish_reason: 'stop',
+ index: 0,
+ }),
+ );
+ emittedFinish = true;
+ stoppedLocally = true;
+ emitted = true;
+ continue;
+ }
+
+ const pendingLength = getPendingStopPrefixLength(
+ pendingStopText,
+ stopSequences,
+ );
+ const content = pendingStopText.slice(
+ 0,
+ pendingStopText.length - pendingLength,
+ );
+ pendingStopText = pendingLength
+ ? pendingStopText.slice(-pendingLength)
+ : '';
+ if (!content) continue;
+ controller.enqueue(
+ encodeChunk({ delta: { content }, index: 0 }),
+ );
+ emitted = true;
+ continue;
+ }
+ controller.enqueue(
+ encodeChunk({
+ delta: { content: delta },
+ index: 0,
+ }),
+ );
+ emitted = true;
+ continue;
+ }
+ if (
+ event.type === 'response.reasoning_summary_text.delta' ||
+ event.type === 'response.reasoning_text.delta'
+ ) {
+ controller.enqueue(
+ encodeChunk({
+ delta: { reasoning_content: String(event.delta ?? '') },
+ index: 0,
+ }),
+ );
+ emitted = true;
+ continue;
+ }
+ if (
+ event.type === 'response.output_item.added' &&
+ event.item &&
+ typeof event.item === 'object'
+ ) {
+ const item = event.item as {
+ arguments?: unknown;
+ call_id?: unknown;
+ id?: unknown;
+ name?: unknown;
+ type?: unknown;
+ };
+ if (item.type !== 'function_call') continue;
+ const itemId = String(item.id ?? item.call_id ?? nextToolIndex);
+ const index = getToolIndex(itemId);
+ const callId = String(item.call_id ?? item.id ?? itemId);
+ toolCallIds.set(itemId, callId);
+ hasToolCalls = true;
+ controller.enqueue(
+ encodeChunk({
+ delta: {
+ tool_calls: [
+ {
+ function: {
+ arguments: String(item.arguments ?? ''),
+ name: String(item.name ?? 'function'),
+ },
+ id: callId,
+ index,
+ type: 'function',
+ },
+ ],
+ },
+ index: 0,
+ }),
+ );
+ emitted = true;
+ continue;
+ }
+ if (event.type === 'response.function_call_arguments.delta') {
+ const itemId = String(
+ event.item_id ?? event.output_index ?? nextToolIndex,
+ );
+ const index = getToolIndex(itemId);
+ const callId = toolCallIds.get(itemId) ?? `call_${index + 1}`;
+ toolCallIds.set(itemId, callId);
+ hasToolCalls = true;
+ controller.enqueue(
+ encodeChunk({
+ delta: {
+ tool_calls: [
+ {
+ function: { arguments: String(event.delta ?? '') },
+ id: callId,
+ index,
+ },
+ ],
+ },
+ index: 0,
+ }),
+ );
+ emitted = true;
+ continue;
+ }
+ if (event.type === 'response.completed') {
+ if (pendingStopText) {
+ controller.enqueue(
+ encodeChunk({
+ delta: { content: pendingStopText },
+ index: 0,
+ }),
+ );
+ pendingStopText = '';
+ }
+ if (!emittedFinish) {
+ controller.enqueue(
+ encodeChunk({
+ delta: {},
+ finish_reason: hasToolCalls ? 'tool_calls' : 'stop',
+ index: 0,
+ }),
+ );
+ }
+ enqueueUsage(controller);
+ emittedFinish = true;
+ emitted = true;
+ if (stoppedLocally) {
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await cancelAndReleaseReader('Stop sequence matched');
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ continue;
+ }
+ if (event.type === 'response.incomplete') {
+ if (pendingStopText) {
+ controller.enqueue(
+ encodeChunk({
+ delta: { content: pendingStopText },
+ index: 0,
+ }),
+ );
+ pendingStopText = '';
+ }
+ const incompleteReason =
+ event.response && typeof event.response === 'object'
+ ? (
+ (event.response as { incomplete_details?: unknown })
+ .incomplete_details as { reason?: unknown } | undefined
+ )?.reason
+ : undefined;
+ if (!emittedFinish) {
+ controller.enqueue(
+ encodeChunk({
+ delta: {},
+ finish_reason:
+ incompleteReason === 'content_filter'
+ ? 'content_filter'
+ : 'length',
+ index: 0,
+ }),
+ );
+ }
+ enqueueUsage(controller);
+ emittedFinish = true;
+ emitted = true;
+ if (stoppedLocally) {
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await cancelAndReleaseReader('Stop sequence matched');
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ continue;
+ }
+ if (
+ event.type === 'response.failed' ||
+ event.type === 'response.error' ||
+ event.type === 'error'
+ ) {
+ const failure =
+ event.error ??
+ (event.response && typeof event.response === 'object'
+ ? (event.response as { error?: unknown }).error
+ : undefined);
+ const message =
+ failure && typeof failure === 'object'
+ ? String(
+ (failure as { message?: unknown }).message ?? failure,
+ )
+ : String(failure ?? 'Upstream Responses stream failed');
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({ error: { message } })}\n\n`,
+ ),
+ );
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ await cancelAndReleaseReader();
+ await recordStreamUsage();
+ controller.close();
+ return;
+ }
+ } catch {
+ // Ignore malformed upstream events and continue reading.
+ }
+ }
+ if (stoppedLocally) continue;
+ if (emitted) return;
+ }
+ },
+ async cancel(reason) {
+ await cancelAndReleaseReader(reason);
+ await recordStreamUsage();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ },
+ status: upstreamResponse.status,
+ });
+};
+
const aggregateToolCalls = (
toolCalls: NonNullable,
): Array<{
@@ -1383,6 +2411,107 @@ export const proxyChatCompletions = async (
context ?? (await resolveProxyContext(request, body.model));
setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename);
const upstreamBody = await buildUpstreamBody(body, resolvedContext);
+
+ if (resolvedContext.preferences.upstreamProtocol === 'responses') {
+ const unsupportedOptions = getUnsupportedResponsesChatOptions(body);
+ if (unsupportedOptions.length) {
+ return createErrorResponse(
+ 400,
+ `Unsupported Chat options for Responses upstream: ${unsupportedOptions.join(', ')}`,
+ );
+ }
+ const apiEndpoint = await getCodeBuddyApiEndpoint();
+ const upstreamUrl = `${apiEndpoint}/responses`;
+ const upstreamHeaders = new Headers(
+ await buildUpstreamHeaders(request, resolvedContext.auth),
+ );
+ upstreamHeaders.set('User-Agent', 'TCodex/0.0.16 CLI/0.144.5');
+ upstreamHeaders.set('X-IDE-Name', 'TCodex');
+ upstreamHeaders.set('X-IDE-Version', '0.144.5');
+ upstreamHeaders.set('X-Product-Version', '0.0.16');
+ const responsesBody = {
+ ...buildResponsesBodyFromChat(upstreamBody),
+ stream: Boolean(body.stream),
+ };
+
+ setDebugUpstreamRequest(debugTrace, {
+ body: responsesBody,
+ headers: headersToRecord(upstreamHeaders),
+ method: 'POST',
+ url: upstreamUrl,
+ });
+
+ let upstreamResponse = await fetch(upstreamUrl, {
+ method: 'POST',
+ headers: upstreamHeaders,
+ body: JSON.stringify(responsesBody),
+ cache: 'no-store',
+ });
+ upstreamResponse = enqueueUpstreamResponseSnapshot(
+ debugTrace,
+ upstreamResponse,
+ );
+
+ if (!upstreamResponse.ok) {
+ const detail = await upstreamResponse.text();
+ logUpstreamFailure({
+ detail,
+ route: usageRoute,
+ status: upstreamResponse.status,
+ url: upstreamUrl,
+ });
+ setDebugTraceError(debugTrace, detail);
+ return createErrorResponse(
+ upstreamResponse.status,
+ 'Upstream CodeBuddy request failed',
+ detail,
+ );
+ }
+
+ if (body.stream) {
+ return mapResponsesStreamToChat(
+ upstreamResponse,
+ String(upstreamBody.model ?? 'unknown'),
+ resolvedContext,
+ usageRoute,
+ body.stop,
+ Boolean(body.stream_options?.include_usage) ||
+ usageRoute === '/v1/messages',
+ );
+ }
+
+ const payload = (await upstreamResponse.json()) as Record<
+ string,
+ unknown
+ >;
+ await recordProxyUsage({
+ model: String(upstreamBody.model ?? 'unknown'),
+ proxyContext: resolvedContext,
+ route: usageRoute,
+ usage: payload.usage ?? null,
+ });
+ if (payload.status === 'failed' || payload.error) {
+ const error =
+ payload.error && typeof payload.error === 'object'
+ ? (payload.error as { message?: unknown })
+ : undefined;
+ return createErrorResponse(
+ 502,
+ typeof error?.message === 'string'
+ ? error.message
+ : 'Upstream Responses request failed',
+ payload.error,
+ );
+ }
+ return Response.json(
+ mapResponsesPayloadToChat(
+ payload,
+ String(upstreamBody.model ?? 'unknown'),
+ body.stop,
+ ),
+ );
+ }
+
const apiEndpoint = await getCodeBuddyApiEndpoint();
const upstreamUrl = `${apiEndpoint}/v2/chat/completions`;
const upstreamHeaders = await buildUpstreamHeaders(
@@ -1493,6 +2622,7 @@ export const proxyResponsesUpstream = async (
body: Record,
context?: ProxyContext,
debugTrace?: DebugTrace,
+ onResponseId?: (responseId: string) => Promise,
): Promise => {
try {
const resolvedContext =
@@ -1503,18 +2633,21 @@ export const proxyResponsesUpstream = async (
));
setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename);
const upstreamBody = {
- ...body,
+ ...normalizeResponsesUpstreamBody(body),
model:
typeof body.model === 'string' && body.model.trim()
? body.model
: await getDefaultModel(),
};
const apiEndpoint = await getCodeBuddyApiEndpoint();
- const upstreamUrl = `${apiEndpoint}/v1/responses`;
- const upstreamHeaders = await buildUpstreamHeaders(
- request,
- resolvedContext.auth,
+ const upstreamUrl = `${apiEndpoint}/responses`;
+ const upstreamHeaders = new Headers(
+ await buildUpstreamHeaders(request, resolvedContext.auth),
);
+ upstreamHeaders.set('User-Agent', 'TCodex/0.0.16 CLI/0.144.5');
+ upstreamHeaders.set('X-IDE-Name', 'TCodex');
+ upstreamHeaders.set('X-IDE-Version', '0.144.5');
+ upstreamHeaders.set('X-Product-Version', '0.0.16');
setDebugUpstreamRequest(debugTrace, {
body: upstreamBody,
@@ -1558,15 +2691,20 @@ export const proxyResponsesUpstream = async (
if (contentType.toLowerCase().includes('application/json')) {
const payloadText = await upstreamResponse.text();
let usage = fallbackUsage;
+ let responseId: string | null = null;
try {
- usage =
- extractResponsesUsage(JSON.parse(payloadText) as unknown) ??
- fallbackUsage;
+ const payload = JSON.parse(payloadText) as unknown;
+ usage = extractResponsesUsage(payload) ?? fallbackUsage;
+ responseId = extractResponsesId(payload);
} catch {
// Preserve malformed upstream JSON while retaining header usage.
}
+ if (responseId && onResponseId) {
+ await onResponseId(responseId);
+ }
+
await recordProxyUsage({
model,
proxyContext: resolvedContext,
@@ -1584,6 +2722,7 @@ export const proxyResponsesUpstream = async (
return trackResponsesUsageStream({
fallbackUsage,
model,
+ onResponseId,
proxyContext: resolvedContext,
upstreamResponse,
});
@@ -1605,7 +2744,7 @@ export const proxyResponsesUpstream = async (
logUpstreamFailure({
error,
route: '/v1/responses',
- url: `${await getCodeBuddyApiEndpoint()}/v1/responses`,
+ url: `${await getCodeBuddyApiEndpoint()}/responses`,
});
return createErrorResponse(
500,
diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts
index 504162e..e4ed402 100644
--- a/lib/server/proxy/responses.ts
+++ b/lib/server/proxy/responses.ts
@@ -68,6 +68,7 @@ interface ResponseSession {
model: string;
transcript: TranscriptMessage[];
defaults: ResponseSessionDefaults;
+ upstreamProtocol?: 'chat' | 'responses';
}
interface ChatResponseToolCall {
@@ -310,6 +311,27 @@ const storeResponseSession = async (
pruneResponseSessions();
};
+const storeUpstreamResponseBinding = async ({
+ model,
+ proxyContext,
+ responseId,
+}: {
+ model: string;
+ proxyContext: ProxyContext;
+ responseId: string;
+}): Promise => {
+ await storeResponseSession({
+ accessKeyId: proxyContext.accessKeyId,
+ credentialFilename: proxyContext.credentialFilename,
+ createdAt: Date.now(),
+ defaults: {},
+ id: responseId,
+ model,
+ transcript: [],
+ upstreamProtocol: 'responses',
+ });
+};
+
const flattenNamespaceToolName = (namespace: string, name: string): string => {
return `${namespace}__${name}`;
};
@@ -1083,6 +1105,7 @@ const mapChatResponseToResponsesPayload = async (
},
],
defaults,
+ upstreamProtocol: 'chat',
});
return {
@@ -1307,6 +1330,7 @@ const createResponsesEventStream = async (
},
],
defaults,
+ upstreamProtocol: 'chat',
});
} catch (error) {
console.error(
@@ -1624,7 +1648,7 @@ export const handleResponsesRequest = async (
throw new Error('Unknown or expired previous_response_id');
}
- const proxyContext = storedPreviousSession?.credentialFilename
+ const resolvedProxyContext = storedPreviousSession?.credentialFilename
? await resolveProxyContextByCredentialFilename(
storedPreviousSession.credentialFilename,
{
@@ -1642,25 +1666,45 @@ export const handleResponsesRequest = async (
request,
typeof body.model === 'string' ? body.model : undefined,
);
+ const proxyContext = storedPreviousSession?.upstreamProtocol
+ ? {
+ ...resolvedProxyContext,
+ preferences: {
+ ...resolvedProxyContext.preferences,
+ upstreamProtocol: storedPreviousSession.upstreamProtocol,
+ },
+ }
+ : resolvedProxyContext;
const scopedBody =
- !storedPreviousSession &&
- (typeof body.model !== 'string' || !body.model.trim())
+ typeof body.model !== 'string' || !body.model.trim()
? {
...body,
model:
+ storedPreviousSession?.model ??
getCredentialSupportedModels(
proxyContext.auth.credentialData,
- )[0] ?? (await getDefaultModel()),
+ )[0] ??
+ (await getDefaultModel()),
}
: body;
- if (proxyContext.preferences.responsesPassthrough) {
+ if (proxyContext.preferences.upstreamProtocol === 'responses') {
+ const model = String(
+ (scopedBody as ResponsesRequestBody).model ?? 'unknown',
+ );
return proxyResponsesUpstream(
request,
scopedBody as Record,
proxyContext,
debugTrace,
+ async (responseId) => {
+ await storeUpstreamResponseBinding({
+ model,
+ proxyContext,
+ responseId,
+ });
+ },
);
}
diff --git a/messages/en-US.json b/messages/en-US.json
index 70dd76e..fcb1a8c 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -229,9 +229,12 @@
"credentialEditTitle": "Edit credential settings",
"credentialEmpty": "No credentials yet. Authenticate or add one manually first.",
"credentialExpired": "Expired credentials",
- "credentialResponsesDirect": "Send Responses requests upstream directly",
- "credentialResponsesDirectHelp": "When enabled, `/v1/responses` requests using this credential are sent upstream directly instead of being converted into Chat Completions.",
- "credentialResponsesProxyTag": "Responses → Chat",
+ "credentialUpstreamProtocol": "Upstream protocol",
+ "credentialUpstreamProtocolHelp": "Chat sends requests to Chat Completions. Responses converts every supported downstream API to the upstream Responses protocol.",
+ "credentialUpstreamChat": "Chat",
+ "credentialUpstreamResponses": "Responses",
+ "credentialUpstreamChatTag": "Upstream: Chat",
+ "credentialUpstreamResponsesTag": "Upstream: Responses",
"credentialRoleAsSystem": "Normalize developer messages for upstream",
"credentialRoleAsSystemHelp": "Sends every developer message as user.",
"credentialRoleAsSystemTag": "developer → user",
diff --git a/messages/ja-JP.json b/messages/ja-JP.json
index d34eb60..4344a78 100644
--- a/messages/ja-JP.json
+++ b/messages/ja-JP.json
@@ -229,9 +229,12 @@
"credentialEditTitle": "認証情報設定を編集",
"credentialEmpty": "認証情報がありません。先に認証するか手動で追加してください。",
"credentialExpired": "期限切れの認証情報",
- "credentialResponsesDirect": "Responses リクエストを上流へ直接送信",
- "credentialResponsesDirectHelp": "有効にすると、この認証情報で処理される `/v1/responses` リクエストは Chat Completions へ変換せず上流へ直接送信されます。",
- "credentialResponsesProxyTag": "Responses → Chat",
+ "credentialUpstreamProtocol": "上流プロトコル",
+ "credentialUpstreamProtocolHelp": "Chat は Chat Completions に送信します。Responses は対応するすべての下流 API を上流の Responses プロトコルに変換します。",
+ "credentialUpstreamChat": "Chat",
+ "credentialUpstreamResponses": "Responses",
+ "credentialUpstreamChatTag": "上流: Chat",
+ "credentialUpstreamResponsesTag": "上流: Responses",
"credentialRoleAsSystem": "developer メッセージを上流向けに正規化",
"credentialRoleAsSystemHelp": "すべての developer メッセージを user として送信します。",
"credentialRoleAsSystemTag": "developer → user",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index aa4987c..f9fee34 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -225,9 +225,12 @@
"credentialEditTitle": "编辑凭证配置",
"credentialEmpty": "暂无凭证,请先认证或手动添加。",
"credentialExpired": "已过期凭证",
- "credentialResponsesDirect": "直接转发 Responses 请求至上游",
- "credentialResponsesDirectHelp": "启用后,使用此凭证的 `/v1/responses` 请求将直接发送到上游,不再转换为 Chat Completions。",
- "credentialResponsesProxyTag": "Responses → Chat",
+ "credentialUpstreamProtocol": "上游协议",
+ "credentialUpstreamProtocolHelp": "Chat 将请求发送到 Chat Completions;Responses 会将所有受支持的下游接口转换为上游 Responses 协议。",
+ "credentialUpstreamChat": "Chat",
+ "credentialUpstreamResponses": "Responses",
+ "credentialUpstreamChatTag": "上游:Chat",
+ "credentialUpstreamResponsesTag": "上游:Responses",
"credentialRoleAsSystem": "转换 developer 消息角色以兼容上游",
"credentialRoleAsSystemHelp": "所有 developer 消息均作为 user 发送。",
"credentialRoleAsSystemTag": "developer → user",
diff --git a/package.json b/package.json
index 0bc8b17..a71c677 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"@eslint/compat": "^2.1.0",
"@next/eslint-plugin-next": "^16.3.0",
"@tailwindcss/postcss": "^4.3.3",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.0",
"@types/better-sqlite3": "^9.6.0",
diff --git a/tests/admin/initial-state.test.ts b/tests/admin/initial-state.test.ts
index 43bd86e..be21055 100644
--- a/tests/admin/initial-state.test.ts
+++ b/tests/admin/initial-state.test.ts
@@ -60,6 +60,7 @@ describe('admin initial state', () => {
is_expired: false,
name: null,
responses_passthrough: false,
+ upstream_protocol: 'chat',
scope: null,
session_state: null,
tenant_id: null,
@@ -82,6 +83,7 @@ describe('admin initial state', () => {
is_expired: false,
name: null,
responses_passthrough: false,
+ upstream_protocol: 'chat',
scope: null,
session_state: null,
tenant_id: null,
diff --git a/tests/server/anthropic.test.ts b/tests/server/anthropic.test.ts
index b28c5eb..ff92c45 100644
--- a/tests/server/anthropic.test.ts
+++ b/tests/server/anthropic.test.ts
@@ -4,6 +4,7 @@ import path from 'node:path';
import { NextRequest } from 'next/server';
import { handleMessagesRequest } from '@/lib/server/proxy/anthropic';
+import { createAccessKey } from '@/lib/server/domain/access-keys';
import { addCredential } from '@/lib/server/domain/credentials';
const repoRoot = process.cwd();
@@ -72,6 +73,168 @@ describe('anthropic messages api', () => {
expect(json.type).toBe('error');
});
+ it('uses Anthropic errors and converts thinking for Responses upstream', async () => {
+ const credential = await addCredential({
+ bearer_token: 'anthropic-responses-token',
+ upstream_protocol: 'responses',
+ user_id: 'anthropic-responses@example.com',
+ });
+ const accessKey = await createAccessKey({
+ credentialFilenames: [credential.filename],
+ name: 'Anthropic Responses Key',
+ });
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ id: 'resp_anthropic_reasoning',
+ output: [
+ {
+ type: 'reasoning',
+ summary: [{ text: 'Think first', type: 'summary_text' }],
+ },
+ ],
+ output_text: 'answerENDignored',
+ status: 'completed',
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ error: { message: 'Responses model failed' },
+ id: 'resp_anthropic_failed',
+ status: 'failed',
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse(
+ { error: { message: 'Responses tenant is rate limited' } },
+ 429,
+ ),
+ );
+ const request = makeNextRequest('http://localhost/v1/messages', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${accessKey.secret}` },
+ });
+
+ const response = await handleMessagesRequest(request, {
+ model: 'hy3',
+ max_tokens: 4096,
+ messages: [{ role: 'user', content: 'Think' }],
+ stop_sequences: ['END'],
+ thinking: { type: 'adaptive' },
+ tool_choice: { disable_parallel_tool_use: true, type: 'auto' },
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ content: [
+ { thinking: 'Think first', type: 'thinking' },
+ { text: 'answer', type: 'text' },
+ ],
+ type: 'message',
+ });
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)),
+ ).toMatchObject({
+ parallel_tool_calls: false,
+ reasoning: { summary: 'auto' },
+ });
+
+ const failedResponse = await handleMessagesRequest(request, {
+ model: 'hy3',
+ max_tokens: 4096,
+ messages: [{ role: 'user', content: 'Fail' }],
+ });
+ expect(failedResponse.status).toBe(502);
+ expect(await failedResponse.json()).toMatchObject({
+ error: {
+ message: 'Responses model failed',
+ type: 'api_error',
+ },
+ type: 'error',
+ });
+
+ const limitedResponse = await handleMessagesRequest(request, {
+ model: 'hy3',
+ max_tokens: 4096,
+ messages: [{ role: 'user', content: 'Retry later' }],
+ });
+ expect(limitedResponse.status).toBe(429);
+ expect(await limitedResponse.json()).toMatchObject({
+ error: {
+ message: 'Responses tenant is rate limited',
+ type: 'rate_limit_error',
+ },
+ type: 'error',
+ });
+ });
+
+ it('maps upstream failures to Anthropic error types', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
+ .mockResolvedValueOnce(new Response('forbidden', { status: 403 }))
+ .mockResolvedValueOnce(new Response('missing', { status: 404 }))
+ .mockResolvedValueOnce(new Response('too large', { status: 413 }))
+ .mockResolvedValueOnce(new Response('overloaded', { status: 529 }))
+ .mockResolvedValueOnce(new Response('bad request', { status: 400 }));
+ const request = makeNextRequest('http://localhost/v1/messages', {
+ method: 'POST',
+ });
+ const expected = [
+ [401, 'authentication_error'],
+ [403, 'permission_error'],
+ [404, 'not_found_error'],
+ [413, 'request_too_large'],
+ [529, 'overloaded_error'],
+ [400, 'invalid_request_error'],
+ ] as const;
+
+ for (const [status, type] of expected) {
+ const response = await handleMessagesRequest(request, {
+ model: 'hy3',
+ max_tokens: 32,
+ messages: [{ role: 'user', content: 'Fail' }],
+ });
+ expect(response.status).toBe(status);
+ expect(await response.json()).toMatchObject({ error: { type } });
+ }
+ });
+
+ it('preserves Responses usage in Anthropic streams', async () => {
+ const credential = await addCredential({
+ bearer_token: 'anthropic-responses-stream-token',
+ upstream_protocol: 'responses',
+ user_id: 'anthropic-responses-stream@example.com',
+ });
+ const accessKey = await createAccessKey({
+ credentialFilenames: [credential.filename],
+ name: 'Anthropic Responses Stream Key',
+ });
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ makeSseResponse([
+ 'data: {"type":"response.output_text.delta","delta":"answer"}',
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}',
+ 'data: [DONE]',
+ ]),
+ );
+
+ const response = await handleMessagesRequest(
+ makeNextRequest('http://localhost/v1/messages', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${accessKey.secret}` },
+ }),
+ {
+ model: 'hy3',
+ max_tokens: 1024,
+ stream: true,
+ messages: [{ role: 'user', content: 'Count usage' }],
+ },
+ );
+ const payload = await response.text();
+
+ expect(payload).toContain('"text":"answer"');
+ expect(payload).toContain('"usage":{"input_tokens":3,"output_tokens":2');
+ });
+
it('translates a simple non-streaming request and response', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
makeJsonResponse({
@@ -588,6 +751,28 @@ describe('anthropic messages api', () => {
expect(text).not.toContain('event: message_stop');
});
+ it('preserves upstream Chat stream errors', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ makeSseResponse([
+ 'data: {"error":{"message":"Responses upstream is unavailable"}}',
+ ]),
+ );
+
+ const response = await handleMessagesRequest(
+ makeNextRequest('http://localhost/v1/messages', { method: 'POST' }),
+ {
+ model: 'claude-sonnet-4.6',
+ max_tokens: 1024,
+ stream: true,
+ messages: [{ role: 'user', content: 'Hi' }],
+ },
+ );
+
+ const text = await response.text();
+ expect(text).toContain('event: error');
+ expect(text).toContain('Responses upstream is unavailable');
+ });
+
it('cancels the upstream Chat stream when an Anthropic client disconnects', async () => {
const cancel = vi.fn();
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts
index ea23881..9dea633 100644
--- a/tests/server/units.test.ts
+++ b/tests/server/units.test.ts
@@ -1320,6 +1320,7 @@ describe('server units', () => {
'00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01',
);
expect(upstreamHeaders.get('tracestate')).toBe('vendor=value');
+ expect(upstreamHeaders.get('Authorization')).toBe('Bearer token-a');
expect(
JSON.parse(String((fetchMock.mock.calls[2]?.[1] as RequestInit).body))
.max_tokens,
@@ -1334,6 +1335,987 @@ describe('server units', () => {
).toBeUndefined();
});
+ it('uses the Responses upstream protocol for Chat Completions requests', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses@example.com',
+ },
+ filePath: '/tmp/responses.json',
+ filename: 'responses.json',
+ });
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ makeJsonResponse({
+ created_at: 123,
+ id: 'resp_123',
+ output: [],
+ output_text: 'hello from responses',
+ usage: {
+ input_tokens: 2,
+ input_tokens_details: {
+ cached_tokens: 1,
+ cache_creation_tokens: 1,
+ },
+ output_tokens: 3,
+ total_tokens: 5,
+ },
+ }),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [
+ { content: 'Follow instructions', role: 'system' },
+ {
+ content: [
+ { text: 'Say hello', type: 'text' },
+ {
+ image_url: { detail: 'high', url: 'https://example.com/a.png' },
+ type: 'image_url',
+ },
+ ],
+ role: 'user',
+ },
+ ],
+ model: 'hy3',
+ reasoning_effort: 'high',
+ parallel_tool_calls: false,
+ response_format: {
+ json_schema: {
+ name: 'answer',
+ schema: { properties: {}, type: 'object' },
+ strict: true,
+ },
+ type: 'json_schema',
+ },
+ tool_choice: {
+ function: { name: 'lookup_weather' },
+ type: 'function',
+ },
+ temperature: 0.2,
+ top_p: 0.8,
+ tools: [
+ {
+ function: {
+ name: 'lookup_weather',
+ parameters: { properties: {}, type: 'object' },
+ },
+ type: 'function',
+ },
+ ],
+ },
+ context,
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ choices: [
+ { message: { content: 'hello from responses', role: 'assistant' } },
+ ],
+ model: 'hy3',
+ object: 'chat.completion',
+ });
+ expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
+ 'https://copilot.tencent.com/responses',
+ );
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)),
+ ).toMatchObject({
+ input: [
+ {
+ content: [
+ { text: 'Say hello', type: 'input_text' },
+ {
+ detail: 'high',
+ image_url: 'https://example.com/a.png',
+ type: 'input_image',
+ },
+ ],
+ role: 'user',
+ },
+ ],
+ instructions: 'Follow instructions',
+ model: 'hy3',
+ parallel_tool_calls: false,
+ reasoning: { effort: 'high' },
+ stream: false,
+ temperature: 0.2,
+ text: {
+ format: {
+ name: 'answer',
+ schema: { properties: {}, type: 'object' },
+ strict: true,
+ type: 'json_schema',
+ },
+ },
+ tool_choice: { name: 'lookup_weather', type: 'function' },
+ tools: [
+ {
+ name: 'lookup_weather',
+ parameters: { properties: {}, type: 'object' },
+ type: 'function',
+ },
+ ],
+ top_p: 0.8,
+ });
+ expect((await getUsageAnalytics({ range: 'today' })).tableRows).toEqual([
+ {
+ callCount: 1,
+ cacheHitTokens: 1,
+ model: 'hy3',
+ totalTokens: 5,
+ },
+ ]);
+ const usageStore = JSON.parse(
+ fs.readFileSync(path.join(tempDataDir, 'usage-history.json'), 'utf8'),
+ ) as { events: Array> };
+ expect(usageStore.events[0]).toMatchObject({
+ cacheCreationTokens: 1,
+ cacheReadTokens: 1,
+ });
+ });
+
+ it('covers Responses upstream compatibility input variants', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-compatibility-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-compatibility@example.com',
+ },
+ filePath: '/tmp/responses-compatibility.json',
+ filename: 'responses-compatibility.json',
+ });
+ const upstreamPayload = {
+ incomplete_details: { reason: 'content_filter' },
+ output: [
+ null,
+ 1,
+ { type: 'other' },
+ { type: 'function_call' },
+ { content: null, type: 'message' },
+ {
+ content: [
+ null,
+ 1,
+ { text: 1, type: 'output_text' },
+ { text: 'from output', type: 'output_text' },
+ ],
+ type: 'message',
+ },
+ {
+ content: [{ text: 'summary content' }],
+ summary: [null, 1, { text: 1 }, { text: 'summary' }],
+ type: 'reasoning',
+ },
+ ],
+ status: 'incomplete',
+ usage: { input_tokens: 2, output_tokens: 3 },
+ };
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockImplementation(async () => makeJsonResponse(upstreamPayload));
+ const request = async (
+ body: Parameters[1],
+ ) => {
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ body,
+ context,
+ );
+ await response.text();
+ return response.status;
+ };
+
+ expect(
+ await request({
+ messages: [
+ { content: null, role: 'system' },
+ {
+ content: [
+ 'plain',
+ null,
+ { text: null },
+ { unknown: true },
+ {
+ image_url: 'https://example.com/string.png',
+ type: 'image_url',
+ },
+ {
+ image_url: { url: 'https://example.com/object.png' },
+ type: 'image_url',
+ },
+ {
+ image_url: 'https://example.com/input.png',
+ type: 'input_image',
+ },
+ { text: 'text part' },
+ { image_url: { url: 1 }, type: 'image_url' },
+ ],
+ role: 'user',
+ },
+ ],
+ model: 'hy3',
+ response_format: { type: 'json_object' },
+ thinking: { type: 'disabled' },
+ tool_choice: 'auto',
+ tools: [
+ null,
+ 1,
+ { name: 'direct_tool' },
+ { function: { description: 'missing name' }, type: 'function' },
+ ],
+ }),
+ ).toBe(200);
+
+ expect(
+ await request({
+ messages: [
+ {
+ content: null,
+ role: 'assistant',
+ tool_calls: [
+ null,
+ 1,
+ { function: {} },
+ { function: { name: 'lookup' } },
+ ],
+ },
+ { content: { result: true }, role: 'tool', tool_call_id: 'call_1' },
+ ],
+ model: 'hy3',
+ response_format: 1,
+ thinking: { budget_tokens: 1_000, type: 'adaptive' },
+ tool_choice: 1,
+ }),
+ ).toBe(200);
+
+ expect(
+ await request({
+ messages: [
+ {
+ content: 'assistant text',
+ role: 'assistant',
+ tool_calls: [
+ {
+ function: { arguments: '{}', name: 'lookup' },
+ id: 'call_lookup',
+ },
+ ],
+ },
+ ],
+ model: 'hy3',
+ response_format: {
+ json_schema: { description: 'schema', name: 'answer' },
+ type: 'json_schema',
+ },
+ thinking: { budget_tokens: 5_000, type: 'enabled' },
+ tool_choice: { name: 'lookup', type: 'function' },
+ }),
+ ).toBe(200);
+
+ expect(
+ await request({
+ messages: [{ content: undefined, role: 'user' }],
+ model: 'hy3',
+ response_format: { json_schema: {}, type: 'json_schema' },
+ thinking: { budget_tokens: 10_000, type: 'adaptive' },
+ tool_choice: { type: 'function' },
+ }),
+ ).toBe(200);
+
+ expect(
+ await request({
+ messages: [{ content: 'reason', role: 'user' }],
+ model: 'hy3',
+ reasoning_effort: 'medium',
+ response_format: { type: 'text' },
+ thinking: { type: 'enabled' },
+ tool_choice: { type: 'required' },
+ }),
+ ).toBe(200);
+
+ expect(
+ await request({
+ messages: [{ content: 'unsupported', role: 'user' }],
+ model: 'hy3',
+ thinking: { type: 'unknown' },
+ }),
+ ).toBe(400);
+ expect(
+ await request({
+ frequency_penalty: 0,
+ messages: [{ content: 'unsupported', role: 'user' }],
+ model: 'hy3',
+ presence_penalty: 0,
+ }),
+ ).toBe(400);
+
+ expect(fetchMock).toHaveBeenCalledTimes(5);
+ const firstBody = JSON.parse(
+ String((fetchMock.mock.calls[0]?.[1] as RequestInit).body),
+ ) as Record;
+ expect(firstBody).toMatchObject({
+ reasoning: { effort: 'none' },
+ text: { format: { type: 'json_object' } },
+ tool_choice: 'auto',
+ });
+ });
+
+ it('covers Responses payload fallback and stop variants', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-payload-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-payload@example.com',
+ },
+ filePath: '/tmp/responses-payload.json',
+ filename: 'responses-payload.json',
+ });
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ output: [
+ null,
+ 1,
+ { content: null, type: 'message' },
+ {
+ content: [
+ null,
+ 1,
+ { text: 1, type: 'output_text' },
+ { text: 'zSTOPaEND', type: 'output_text' },
+ ],
+ type: 'message',
+ },
+ ],
+ status: 'incomplete',
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ output: [
+ {
+ arguments: undefined,
+ id: 'fc_fallback',
+ name: undefined,
+ type: 'function_call',
+ },
+ ],
+ output_text: '',
+ status: 'completed',
+ usage: {},
+ }),
+ );
+
+ const incompleteResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'fallback', role: 'user' }],
+ model: 'hy3',
+ stop: ['', 'END', 'STOP'],
+ },
+ context,
+ );
+ expect(await incompleteResponse.json()).toMatchObject({
+ choices: [
+ {
+ finish_reason: 'length',
+ message: { content: 'z' },
+ },
+ ],
+ usage: { completion_tokens: 0, prompt_tokens: 0, total_tokens: 0 },
+ });
+
+ const toolResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'tool fallback', role: 'user' }],
+ model: 'hy3',
+ },
+ context,
+ );
+ expect(await toolResponse.json()).toMatchObject({
+ choices: [
+ {
+ finish_reason: 'tool_calls',
+ message: {
+ content: null,
+ tool_calls: [
+ {
+ function: { arguments: '', name: 'function' },
+ id: 'fc_fallback',
+ },
+ ],
+ },
+ },
+ ],
+ });
+ });
+
+ it('applies Chat stop sequences locally for the Responses upstream', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-options-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-options@example.com',
+ },
+ filePath: '/tmp/responses-options.json',
+ filename: 'responses-options.json',
+ });
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ makeJsonResponse({
+ id: 'resp_stopped',
+ output: [],
+ output_text: 'beforeENDafter',
+ status: 'completed',
+ }),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Stop early', role: 'user' }],
+ model: 'hy3',
+ stop: ['END'],
+ },
+ context,
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ choices: [
+ {
+ finish_reason: 'stop',
+ message: { content: 'before' },
+ },
+ ],
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)),
+ ).not.toHaveProperty('stop');
+ });
+
+ it('maps Responses reasoning and semantic failures to Chat', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-reasoning-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-reasoning@example.com',
+ },
+ filePath: '/tmp/responses-reasoning.json',
+ filename: 'responses-reasoning.json',
+ });
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ id: 'resp_reasoning',
+ output: [
+ {
+ type: 'reasoning',
+ summary: [{ text: 'Reasoning summary', type: 'summary_text' }],
+ },
+ ],
+ output_text: 'answer',
+ status: 'completed',
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ error: { code: 'server_error', message: 'model failed' },
+ id: 'resp_failed',
+ status: 'failed',
+ }),
+ );
+
+ const reasoningResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Think', role: 'user' }],
+ model: 'hy3',
+ thinking: { type: 'adaptive' },
+ },
+ context,
+ );
+ expect(await reasoningResponse.json()).toMatchObject({
+ choices: [
+ {
+ message: {
+ content: 'answer',
+ reasoning_content: 'Reasoning summary',
+ },
+ },
+ ],
+ });
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)),
+ ).toMatchObject({ reasoning: { summary: 'auto' } });
+
+ const failedResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Fail', role: 'user' }],
+ model: 'hy3',
+ },
+ context,
+ );
+ expect(failedResponse.status).toBe(502);
+ expect(await failedResponse.text()).toContain('model failed');
+ });
+
+ it('maps Responses content filtering and split stop sequences in streams', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-stream-stop-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-stream-stop@example.com',
+ },
+ filePath: '/tmp/responses-stream-stop.json',
+ filename: 'responses-stream-stop.json',
+ });
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.output_text.delta","delta":"beforeEN"}\n\n' +
+ 'data: {"type":"response.output_text.delta","delta":"Dafter"}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"content_filter"}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const stoppedResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Stop', role: 'user' }],
+ model: 'hy3',
+ stop: 'END',
+ stream: true,
+ },
+ context,
+ );
+ const stoppedText = await stoppedResponse.text();
+ expect(stoppedText).toContain('"content":"before"');
+ expect(stoppedText).not.toContain('after');
+ expect(stoppedText).toContain('"finish_reason":"stop"');
+
+ const filteredResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Filter', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ },
+ context,
+ );
+ expect(await filteredResponse.text()).toContain(
+ '"finish_reason":"content_filter"',
+ );
+ });
+
+ it('emits Responses usage for Chat streams and preserves it after local stops', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-stream-usage-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-stream-usage@example.com',
+ },
+ filePath: '/tmp/responses-stream-usage.json',
+ filename: 'responses-stream-usage.json',
+ });
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.output_text.delta","delta":"normal"}\n\n' +
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.output_text.delta","delta":"beforeENDafter"}\n\n' +
+ 'data: {"type":"response.output_text.delta","delta":"ignored"}\n\n' +
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":4,"output_tokens":3,"total_tokens":7}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const normalResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Normal usage', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ stream_options: { include_usage: true },
+ },
+ context,
+ );
+ const normalPayload = await normalResponse.text();
+ expect(normalPayload).toContain('"choices":[]');
+ expect(normalPayload).toContain(
+ '"prompt_tokens":3,"prompt_tokens_details"',
+ );
+ expect(normalPayload).toContain('"completion_tokens":2');
+
+ const stoppedResponse = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Stopped usage', role: 'user' }],
+ model: 'hy3',
+ stop: 'END',
+ stream: true,
+ stream_options: { include_usage: true },
+ },
+ context,
+ );
+ const stoppedPayload = await stoppedResponse.text();
+ expect(stoppedPayload).toContain('"content":"before"');
+ expect(stoppedPayload).not.toContain('after');
+ expect(stoppedPayload).not.toContain('ignored');
+ expect(stoppedPayload.match(/"finish_reason":"stop"/g)).toHaveLength(1);
+ expect(stoppedPayload).toContain('"completion_tokens":3');
+ expect(stoppedPayload).toContain('"total_tokens":7');
+
+ expect((await getUsageAnalytics({ range: 'today' })).tableRows).toEqual([
+ {
+ callCount: 2,
+ cacheHitTokens: 0,
+ model: 'hy3',
+ totalTokens: 12,
+ },
+ ]);
+ });
+
+ it('records Responses usage when a Chat stream is cancelled', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-cancel-usage-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-cancel-usage@example.com',
+ },
+ filePath: '/tmp/responses-cancel-usage.json',
+ filename: 'responses-cancel-usage.json',
+ });
+ const cancel = vi.fn();
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(new ReadableStream({ cancel }), {
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'x-codebuddy-usage': JSON.stringify({
+ input_tokens: 6,
+ input_tokens_details: { cached_tokens: 4 },
+ output_tokens: 2,
+ total_tokens: 8,
+ }),
+ },
+ }),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Cancel this stream', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ },
+ context,
+ );
+
+ await response.body?.cancel('client disconnected');
+
+ expect(cancel).toHaveBeenCalledWith('client disconnected');
+ expect((await getUsageAnalytics({ range: 'today' })).tableRows).toEqual([
+ {
+ callCount: 1,
+ cacheHitTokens: 4,
+ model: 'hy3',
+ totalTokens: 8,
+ },
+ ]);
+ });
+
+ it('covers Responses Chat stream terminal variants', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-stream-terminal-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-stream-terminal@example.com',
+ },
+ filePath: '/tmp/responses-stream-terminal.json',
+ filename: 'responses-stream-terminal.json',
+ });
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.reasoning_summary_text.delta"}\n\n' +
+ 'data: {"type":"response.reasoning_text.delta","delta":"reason"}\n\n' +
+ 'data: {"type":"response.output_item.added","item":{"type":"function_call"}}\n\n' +
+ 'data: {"type":"response.function_call_arguments.delta","output_index":0}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.output_text.delta","delta":"tailZ"}\n\n' +
+ 'data: {"type":"response.completed","response":{"usage":{}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.output_text.delta","delta":"partialQ"}\n\n' +
+ 'data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"content_filter"}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.error","response":{"error":"response error"}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response('data: {"type":"error","error":{}}\n\n', {
+ headers: { 'Content-Type': 'text/event-stream' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response('data: {"type":"response.failed"}\n\n', {
+ headers: { 'Content-Type': 'text/event-stream' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":4,"output_tokens":3,"input_tokens_details":{"cached_tokens":1,"cache_creation_tokens":2},"output_tokens_details":{"reasoning_tokens":2}}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.completed","response":{"usage":"invalid"}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+ const stream = async (
+ options: Partial[1]> = {},
+ ): Promise => {
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Stream terminal', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ ...options,
+ },
+ context,
+ );
+ return response.text();
+ };
+
+ const toolPayload = await stream();
+ expect(toolPayload).toContain('"reasoning_content":""');
+ expect(toolPayload).toContain('"reasoning_content":"reason"');
+ expect(toolPayload).toContain('"name":"function"');
+ expect(toolPayload).toContain('"finish_reason":"tool_calls"');
+
+ const completedPayload = await stream({
+ stop: 'ZZ',
+ stream_options: { include_usage: true },
+ });
+ expect(completedPayload).toContain('"content":"tail"');
+ expect(completedPayload).toContain('"content":"Z"');
+ expect(completedPayload).toContain('"total_tokens":0');
+
+ const incompletePayload = await stream({ stop: 'QQ' });
+ expect(incompletePayload).toContain('"content":"partial"');
+ expect(incompletePayload).toContain('"content":"Q"');
+ expect(incompletePayload).toContain('"finish_reason":"content_filter"');
+
+ expect(await stream()).toContain('response error');
+ expect(await stream()).toContain('[object Object]');
+ expect(await stream()).toContain('Upstream Responses stream failed');
+
+ const detailedUsagePayload = await stream({
+ stream_options: { include_usage: true },
+ });
+ expect(detailedUsagePayload).toContain('"cached_tokens":1');
+ expect(detailedUsagePayload).toContain('"cache_creation_tokens":2');
+ expect(detailedUsagePayload).toContain('"reasoning_tokens":2');
+
+ const invalidUsagePayload = await stream({
+ stream_options: { include_usage: true },
+ });
+ expect(invalidUsagePayload).not.toContain('"usage":');
+ expect(invalidUsagePayload).toContain('data: [DONE]');
+ });
+
+ it('maps Responses function call events back to Chat Completions SSE', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-stream-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-stream@example.com',
+ },
+ filePath: '/tmp/responses-stream.json',
+ filename: 'responses-stream.json',
+ });
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ 'event: response.output_item.added\n' +
+ 'data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup_weather","arguments":""}}\n\n' +
+ 'event: response.function_call_arguments.delta\n' +
+ 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\\"city\\":\\"Shanghai\\"}"}\n\n' +
+ 'event: response.incomplete\n' +
+ 'data: {"type":"response.incomplete"}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Use the weather tool', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ },
+ context,
+ );
+ const payload = await response.text();
+
+ expect(payload).toContain('"name":"lookup_weather"');
+ expect(payload).toContain('"arguments":"{\\"city\\":\\"Shanghai\\"}"');
+ expect(payload.match(/"id":"call_1"/g)).toHaveLength(2);
+ expect(payload).toContain('"finish_reason":"length"');
+ expect(payload).toContain('data: [DONE]');
+ });
+
+ it('surfaces Responses stream failures as Chat Completions errors', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-error-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-error@example.com',
+ },
+ filePath: '/tmp/responses-error.json',
+ filename: 'responses-error.json',
+ });
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ 'event: response.failed\n' +
+ 'data: {"type":"response.failed","response":{"error":{"message":"upstream failed"}}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Fail upstream', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ },
+ context,
+ );
+ const payload = await response.text();
+
+ expect(payload).toContain('"message":"upstream failed"');
+ expect(payload).not.toContain('"finish_reason":"stop"');
+ expect(payload).toContain('data: [DONE]');
+ });
+
+ it('records Responses usage when a Chat stream reader fails', async () => {
+ const context = createProxyContextFromCredential({
+ data: {
+ bearer_token: 'responses-reader-failure-token',
+ upstream_protocol: 'responses',
+ user_id: 'responses-reader-failure@example.com',
+ },
+ filePath: '/tmp/responses-reader-failure.json',
+ filename: 'responses-reader-failure.json',
+ });
+ const encoder = new TextEncoder();
+ let pullCount = 0;
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ new ReadableStream({
+ pull(controller) {
+ if (pullCount === 0) {
+ pullCount += 1;
+ controller.enqueue(
+ encoder.encode(
+ 'data: {"type":"response.output_text.delta","delta":"partial","response":{"usage":{"input_tokens":4,"output_tokens":3,"total_tokens":7}}}\n\n',
+ ),
+ );
+ return;
+ }
+
+ controller.error(new Error('Responses reader failed'));
+ },
+ }),
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const response = await proxyChatCompletions(
+ makeNextRequest('http://localhost/v1/chat/completions', {
+ method: 'POST',
+ }),
+ {
+ messages: [{ content: 'Fail after usage', role: 'user' }],
+ model: 'hy3',
+ stream: true,
+ },
+ context,
+ );
+
+ await expect(response.text()).rejects.toThrow('Responses reader failed');
+ expect((await getUsageAnalytics({ range: 'today' })).tableRows).toEqual([
+ {
+ callCount: 1,
+ cacheHitTokens: 0,
+ model: 'hy3',
+ totalTokens: 7,
+ },
+ ]);
+ });
+
it('persists successful proxy calls without upstream usage across runtime restarts', async () => {
const credential = (await listCredentials()).credentials[0];
expect(credential).toBeDefined();
@@ -2116,7 +3098,8 @@ describe('server units', () => {
},
},
),
- );
+ )
+ .mockResolvedValueOnce(makeJsonResponse({ output_text: 'normalized' }));
const tools = [
{
@@ -2418,6 +3401,10 @@ describe('server units', () => {
response: {
usage: {
input_tokens: 4,
+ input_tokens_details: {
+ cached_tokens: 1,
+ cache_creation_tokens: 1,
+ },
output_tokens: 2,
total_tokens: 6,
},
@@ -2431,7 +3418,7 @@ describe('server units', () => {
'data: {"type":"response.created","response":{"id":"resp_1"}}',
'',
'event: response.completed',
- 'data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}',
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"input_tokens_details":{"cached_tokens":2,"cache_creation_tokens":1},"output_tokens":3,"total_tokens":8}}}',
'',
].join('\n'),
{
@@ -2458,12 +3445,35 @@ describe('server units', () => {
});
await streamResponse.text();
- expect(fetchMock).toHaveBeenCalledTimes(2);
+ const normalizedResponse = await proxyResponsesUpstream(request, {
+ messages: [
+ { content: 'System compatibility instruction', role: 'system' },
+ { content: 'messages compatibility input', role: 'user' },
+ ],
+ model: 'gpt-5.5',
+ });
+ await normalizedResponse.text();
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[2]?.[1] as RequestInit).body)),
+ ).toMatchObject({
+ input: [
+ {
+ content: [
+ { text: 'messages compatibility input', type: 'input_text' },
+ ],
+ role: 'user',
+ },
+ ],
+ instructions: 'System compatibility instruction',
+ model: 'gpt-5.5',
+ });
await waitForAsync(async () => {
expect((await getUsageAnalytics({ range: 'today' })).tableRows).toEqual([
{
- callCount: 2,
- cacheHitTokens: 0,
+ callCount: 3,
+ cacheHitTokens: 3,
model: 'gpt-5.5',
totalTokens: 14,
},
@@ -2471,6 +3481,46 @@ describe('server units', () => {
});
});
+ it('waits for streamed response bindings before forwarding response ids', async () => {
+ let resolveBinding: (() => void) | undefined;
+ const binding = new Promise((resolve) => {
+ resolveBinding = resolve;
+ });
+ const onResponseId = vi.fn(async () => binding);
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ 'data: {"type":"response.created","response":{"id":"resp_delayed_binding"}}\n\n',
+ { headers: { 'Content-Type': 'text/event-stream' } },
+ ),
+ );
+
+ const response = await proxyResponsesUpstream(
+ makeNextRequest('http://localhost/v1/responses', { method: 'POST' }),
+ { input: 'bind before forwarding', model: 'gpt-5.5', stream: true },
+ undefined,
+ undefined,
+ onResponseId,
+ );
+ const reader = response.body!.getReader();
+ let readSettled = false;
+ const readPromise = reader.read().then((result) => {
+ readSettled = true;
+ return result;
+ });
+
+ await waitForAsync(async () => {
+ expect(onResponseId).toHaveBeenCalledWith('resp_delayed_binding');
+ });
+ expect(readSettled).toBe(false);
+
+ resolveBinding?.();
+ const firstChunk = await readPromise;
+ expect(new TextDecoder().decode(firstChunk.value)).toContain(
+ 'resp_delayed_binding',
+ );
+ expect((await reader.read()).done).toBe(true);
+ });
+
it('cancels the upstream Responses stream when the client disconnects', async () => {
const cancel = vi.fn();
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
@@ -2496,6 +3546,30 @@ describe('server units', () => {
expect(cancel).toHaveBeenCalledWith('client disconnected');
});
+ it('errors the downstream Responses stream when the upstream reader fails', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ new ReadableStream({
+ pull(controller) {
+ controller.error(new Error('upstream connection reset'));
+ },
+ }),
+ {
+ headers: {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ },
+ },
+ ),
+ );
+
+ const response = await proxyResponsesUpstream(
+ makeNextRequest('http://localhost/v1/responses', { method: 'POST' }),
+ { input: 'fail while streaming', model: 'gpt-5.5', stream: true },
+ );
+
+ await expect(response.text()).rejects.toThrow('upstream connection reset');
+ });
+
it('covers responses passthrough header fallback, raw body passthrough, and upstream errors', async () => {
const createdCredential = await addCredential({
bearer_token: 'token-responses',
@@ -2718,6 +3792,137 @@ describe('server units', () => {
).toBe('resp_from_upstream');
});
+ it('pins upstream Responses follow-ups to the original credential', async () => {
+ const firstCredential = await addCredential({
+ bearer_token: 'token-response-binding-a',
+ upstream_protocol: 'responses',
+ user_id: 'response-binding-a@example.com',
+ });
+ const secondCredential = await addCredential({
+ bearer_token: 'token-response-binding-b',
+ upstream_protocol: 'responses',
+ user_id: 'response-binding-b@example.com',
+ });
+ const accessKey = await createAccessKey({
+ credentialFilenames: [
+ firstCredential.filename,
+ secondCredential.filename,
+ ],
+ name: 'Responses Binding Key',
+ });
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ id: 'resp_bound_upstream',
+ object: 'response',
+ output_text: 'first',
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ id: 'resp_bound_follow_up',
+ object: 'response',
+ output_text: 'second',
+ }),
+ );
+ const request = makeNextRequest('http://localhost/v1/responses', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${accessKey.secret}` },
+ });
+
+ const firstResponse = await handleResponsesRequest(request, {
+ input: 'first',
+ model: 'hy3',
+ });
+ expect(firstResponse.status).toBe(200);
+ await firstResponse.text();
+
+ await addCredential(
+ { upstream_protocol: 'chat' },
+ firstCredential.filename,
+ );
+
+ const followUpResponse = await handleResponsesRequest(request, {
+ input: 'second',
+ previous_response_id: 'resp_bound_upstream',
+ });
+ expect(followUpResponse.status).toBe(200);
+ await followUpResponse.text();
+
+ const firstAuthorization = new Headers(
+ (fetchMock.mock.calls[0]?.[1] as RequestInit).headers,
+ ).get('authorization');
+ const secondAuthorization = new Headers(
+ (fetchMock.mock.calls[1]?.[1] as RequestInit).headers,
+ ).get('authorization');
+ expect(secondAuthorization).toBe(firstAuthorization);
+ expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
+ 'https://copilot.tencent.com/responses',
+ );
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[1]?.[1] as RequestInit).body)),
+ ).toMatchObject({ model: 'hy3' });
+ });
+
+ it('keeps Chat-backed Responses sessions on Chat after protocol changes', async () => {
+ const credential = await addCredential({
+ bearer_token: 'token-chat-session-binding',
+ upstream_protocol: 'chat',
+ user_id: 'chat-session-binding@example.com',
+ });
+ const accessKey = await createAccessKey({
+ credentialFilenames: [credential.filename],
+ name: 'Chat Session Binding Key',
+ });
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ choices: [{ message: { content: 'first answer' } }],
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeJsonResponse({
+ choices: [{ message: { content: 'second answer' } }],
+ }),
+ );
+ const request = makeNextRequest('http://localhost/v1/responses', {
+ method: 'POST',
+ headers: { authorization: `Bearer ${accessKey.secret}` },
+ });
+
+ const firstResponse = await handleResponsesRequest(request, {
+ input: 'first question',
+ model: 'hy3',
+ });
+ const firstPayload = (await firstResponse.json()) as { id: string };
+
+ await addCredential(
+ { upstream_protocol: 'responses' },
+ credential.filename,
+ );
+
+ const followUpResponse = await handleResponsesRequest(request, {
+ input: 'second question',
+ previous_response_id: firstPayload.id,
+ });
+ expect(followUpResponse.status).toBe(200);
+ expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
+ 'https://copilot.tencent.com/v2/chat/completions',
+ );
+ expect(
+ JSON.parse(String((fetchMock.mock.calls[1]?.[1] as RequestInit).body)),
+ ).toMatchObject({
+ messages: [
+ { content: 'first question', role: 'user' },
+ { content: 'first answer', role: 'assistant' },
+ { content: 'second question', role: 'user' },
+ ],
+ model: 'hy3',
+ });
+ });
+
it('covers proxy context helpers for saved credentials', async () => {
const createdCredential = await addCredential({
bearer_token: 'token-from-bearer-token',
@@ -2938,7 +4143,41 @@ describe('server units', () => {
);
expect(resolved.auth.bearerToken).toBe('token-updated');
expect(resolved.preferences.firstMessageRoleToSystem).toBe(true);
- expect(resolved.preferences.responsesPassthrough).toBe(false);
+ expect(resolved.preferences.upstreamProtocol).toBe('chat');
+ });
+
+ it('keeps legacy and current upstream protocol fields synchronized', async () => {
+ const created = await addCredential({
+ bearer_token: 'token-protocol-sync',
+ upstream_protocol: 'responses',
+ user_id: 'protocol-sync@example.com',
+ });
+
+ let record = (await readCredentialRecords()).find(
+ (credential) => credential.filename === created.filename,
+ );
+ expect(record?.data).toMatchObject({
+ responses_passthrough: true,
+ upstream_protocol: 'responses',
+ });
+
+ await addCredential({ upstream_protocol: 'chat' }, created.filename);
+ record = (await readCredentialRecords()).find(
+ (credential) => credential.filename === created.filename,
+ );
+ expect(record?.data).toMatchObject({
+ responses_passthrough: false,
+ upstream_protocol: 'chat',
+ });
+
+ await addCredential({ responses_passthrough: true }, created.filename);
+ record = (await readCredentialRecords()).find(
+ (credential) => credential.filename === created.filename,
+ );
+ expect(record?.data).toMatchObject({
+ responses_passthrough: true,
+ upstream_protocol: 'responses',
+ });
});
it('discovers models per credential without live upstream requests', async () => {