From 4a3b4a8bb6ce4098756dc244617a0aad6e7a56da Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 20:25:20 +0800 Subject: [PATCH] fix: preserve protocol parity across compatibility hops (#259) * wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * fix: preserve oe tool_result structure * fix: preserve protocol parity across compatibility hops * fix: satisfy server build and typecheck * fix: preserve codex and mcp fallback semantics * fix: address ci and review follow-ups --------- Co-authored-by: bnvnvnv --- .../providers/codexProviderProfile.ts | 13 +- .../proxy-core/providers/registry.test.ts | 6 +- .../routes/proxy/chat.codex-oauth.test.ts | 8 +- src/server/routes/proxy/chat.stream.test.ts | 1 + src/server/routes/proxy/endpointFlow.test.ts | 16 ++ src/server/routes/proxy/endpointFlow.ts | 10 +- src/server/routes/proxy/images.edits.test.ts | 37 ++-- src/server/routes/proxy/images.ts | 8 + .../proxy/responses.codex-oauth.test.ts | 9 +- .../routes/proxy/upstreamEndpoint.test.ts | 62 +++++-- src/server/routes/proxy/upstreamEndpoint.ts | 36 +--- .../anthropic/messages/conversion.test.ts | 98 +++++++++++ .../anthropic/messages/conversion.ts | 57 ++++++- .../transformers/canonical/request.test.ts | 158 ++++++++++++++++++ src/server/transformers/canonical/request.ts | 107 +++++++++--- src/server/transformers/canonical/tools.ts | 25 ++- src/server/transformers/canonical/types.ts | 5 + .../gemini/generate-content/index.ts | 25 ++- .../openai/responses/conversion.test.ts | 139 +++++++++++++++ .../openai/responses/conversion.ts | 38 ++++- .../openai/responses/mcpCompatibility.ts | 110 ++++++++++++ .../openai/responses/outbound.test.ts | 46 +++++ .../transformers/openai/responses/outbound.ts | 11 ++ .../shared/chatFormatsCore.test.ts | 49 +++++- .../transformers/shared/chatFormatsCore.ts | 47 +++++- 25 files changed, 985 insertions(+), 136 deletions(-) create mode 100644 src/server/transformers/openai/responses/mcpCompatibility.ts diff --git a/src/server/proxy-core/providers/codexProviderProfile.ts b/src/server/proxy-core/providers/codexProviderProfile.ts index 58bb20b..726135b 100644 --- a/src/server/proxy-core/providers/codexProviderProfile.ts +++ b/src/server/proxy-core/providers/codexProviderProfile.ts @@ -30,22 +30,11 @@ export const codexProviderProfile: ProviderProfile = { openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta') || (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null), }); - const codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'); - const shouldInjectDerivedPromptCacheKey = !!codexSessionId - && !asTrimmedString(input.body.prompt_cache_key) - && !asTrimmedString(input.codexExplicitSessionId) - && !!asTrimmedString(input.codexSessionCacheKey); - const body = shouldInjectDerivedPromptCacheKey - ? { - ...input.body, - prompt_cache_key: codexSessionId, - } - : input.body; return { path: '/responses', headers, - body, + body: input.body, runtime: { executor: 'codex', modelName: input.modelName, diff --git a/src/server/proxy-core/providers/registry.test.ts b/src/server/proxy-core/providers/registry.test.ts index cd1fa65..87e2fd5 100644 --- a/src/server/proxy-core/providers/registry.test.ts +++ b/src/server/proxy-core/providers/registry.test.ts @@ -39,7 +39,11 @@ describe('resolveProviderProfile', () => { modelName: 'gpt-5.2-codex', stream: true, }); - expect(result.body.prompt_cache_key).toBe(result.headers.Session_id); + expect(result.body).toEqual({ + model: 'gpt-5.2-codex', + stream: true, + store: false, + }); }); it('builds claude provider requests without rebuilding protocol bodies', () => { diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index ac80e7e..5ee6119 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -177,10 +177,10 @@ describe('chat proxy codex oauth compatibility', () => { const forwardedBody = JSON.parse(options.body); expect(forwardedBody.stream).toBe(true); expect(forwardedBody.instructions).toBe(''); - expect(forwardedBody.store).toBe(false); - expect(forwardedBody.parallel_tool_calls).toBe(true); - expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']); - expect(forwardedBody.max_output_tokens).toBeUndefined(); + expect(forwardedBody.store).toBeUndefined(); + expect(forwardedBody.parallel_tool_calls).toBeUndefined(); + expect(forwardedBody.include).toBeUndefined(); + expect(forwardedBody.max_output_tokens).toBe(256); expect(forwardedBody.max_completion_tokens).toBeUndefined(); const body = response.json(); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 01678e9..a3811cb 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1921,6 +1921,7 @@ describe('chat proxy stream behavior', () => { expect(forwarded.tool_choice?.function?.name).toBe('Glob'); }); + it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => { fetchModelPricingCatalogMock.mockResolvedValue({ models: [ diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 0c6f8e4..42c4399 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -298,6 +298,22 @@ describe('executeEndpointFlow', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); }); + + it('normalizes proxyUrl with versioned base paths instead of duplicating path segments', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + proxyUrl: 'https://proxy.internal/api/v1?mode=relay#frag', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/api/v1/responses?mode=relay#frag'); + }); it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index 67bd0e9..75dc5ba 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -76,12 +76,6 @@ type ExecuteEndpointFlowInput = { onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise; }; -function buildAbsoluteUrl(base: string, path: string): string { - const normalizedBase = base.replace(/\/+$/, ''); - const normalizedPath = path.replace(/^\/+/, ''); - return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase; -} - async function runEndpointFlowHook( hook: ((ctx: T) => void | Promise) | undefined, ctx: T, @@ -113,7 +107,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom const request = input.buildRequest(endpoint, endpointIndex); const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); const targetUrl = input.proxyUrl - ? buildAbsoluteUrl(input.proxyUrl, request.path) + ? buildUpstreamUrl(input.proxyUrl, request.path) : defaultTarget; let response = input.dispatchRequest @@ -155,7 +149,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom const recoveredRequest = recovered.request ?? baseContext.request; const recoveredTargetUrl = recovered.targetUrl ?? ( input.proxyUrl - ? buildAbsoluteUrl(input.proxyUrl, recovered.upstreamPath) + ? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath) : buildUpstreamUrl(input.siteUrl, recovered.upstreamPath) ); await runEndpointFlowHook(input.onAttemptSuccess, { diff --git a/src/server/routes/proxy/images.edits.test.ts b/src/server/routes/proxy/images.edits.test.ts index 13b0cd5..db5507f 100644 --- a/src/server/routes/proxy/images.edits.test.ts +++ b/src/server/routes/proxy/images.edits.test.ts @@ -143,11 +143,27 @@ describe('/v1/images/edits route', () => { expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits'); }); - it('returns an upstream error instead of fabricating an empty success when image generation JSON is malformed', async () => { - fetchMock.mockResolvedValue(new Response('not-json', { - status: 200, - headers: { 'content-type': 'application/json' }, - })); + it('retries the next channel when image generation JSON is malformed', async () => { + selectNextChannelMock.mockReturnValueOnce({ + channel: { id: 12, routeId: 23 }, + site: { id: 45, name: 'fallback-site', url: 'https://fallback.example.com', platform: 'openai' }, + account: { id: 34, username: 'fallback-user' }, + tokenName: 'fallback', + tokenValue: 'sk-fallback', + actualModel: 'fallback-gpt-image', + }); + fetchMock + .mockResolvedValueOnce(new Response('not-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + created: 2, + data: [{ b64_json: 'ZmFsbGJhY2s=' }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); const response = await app.inject({ method: 'POST', @@ -161,14 +177,13 @@ describe('/v1/images/edits route', () => { }, }); - expect(response.statusCode).toBe(502); + expect(response.statusCode).toBe(200); expect(response.json()).toEqual({ - error: { - message: 'not-json', - type: 'upstream_error', - }, + created: 2, + data: [{ b64_json: 'ZmFsbGJhY2s=' }], }); - expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(selectNextChannelMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); }); it('keeps returning a successful image edit response when post-success accounting fails', async () => { diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 4aac1b3..2b6c2f8 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -132,6 +132,10 @@ export async function imagesProxyRoute(app: FastifyInstance) { downstreamPath, clientContext, ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: data.message, @@ -324,6 +328,10 @@ export async function imagesProxyRoute(app: FastifyInstance) { downstreamPath, clientContext, ); + if (canRetryProxyChannel(retryCount)) { + retryCount++; + continue; + } await reportProxyAllFailed({ model: requestedModel, reason: data.message, diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index ea597b0..d24cd8b 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -525,7 +525,8 @@ describe('responses proxy codex oauth refresh', () => { const forwardedBody = JSON.parse(options.body); expect(forwardedBody.stream).toBe(true); expect(forwardedBody.instructions).toBe(''); - expect(forwardedBody.store).toBe(false); + expect(forwardedBody.store).toBeUndefined(); + expect(forwardedBody.max_output_tokens).toBeUndefined(); expect(response.json()).toMatchObject({ id: 'resp_codex_stream', @@ -567,11 +568,13 @@ describe('responses proxy codex oauth refresh', () => { const secondBody = JSON.parse(secondOptions.body); expect(firstBody.instructions).toBe(''); - expect(firstBody.store).toBe(false); + expect(firstBody.store).toBeUndefined(); expect(firstBody.stream).toBe(true); + expect(firstBody.max_output_tokens).toBeUndefined(); expect(secondBody.instructions).toBe(''); - expect(secondBody.store).toBe(false); + expect(secondBody.store).toBeUndefined(); expect(secondBody.stream).toBe(true); + expect(secondBody.max_output_tokens).toBeUndefined(); }); it('does not record success when a streaming responses request ends with response.failed', async () => { diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 8f71fef..8e2404c 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -626,16 +626,16 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.headers.Accept).toBe('text/event-stream'); expect(request.headers.Connection).toBe('Keep-Alive'); expect(request.body.instructions).toBe(''); - expect(request.body.prompt_cache_key).toBe(request.headers.Session_id); - expect(request.body.stream).toBe(true); - expect(request.body.store).toBe(false); - expect(request.body.parallel_tool_calls).toBe(true); - expect(request.body.include).toEqual(['reasoning.encrypted_content']); - expect(request.body.max_output_tokens).toBeUndefined(); - expect(request.body.temperature).toBeUndefined(); - expect(request.body.top_p).toBeUndefined(); - expect(request.body.user).toBeUndefined(); - expect(request.body.service_tier).toBeUndefined(); + expect(request.body.prompt_cache_key).toBeUndefined(); + expect(request.body.stream).toBe(false); + expect(request.body.store).toBeUndefined(); + expect(request.body.parallel_tool_calls).toBeUndefined(); + expect(request.body.include).toBeUndefined(); + expect(request.body.max_output_tokens).toBe(4096); + expect(request.body.temperature).toBe(0.2); + expect(request.body.top_p).toBe(0.9); + expect(request.body.user).toBe('drop-me'); + expect(request.body.service_tier).toBe('auto'); }); it('reuses a stable codex session id when the same downstream continuity key is provided', () => { @@ -729,6 +729,48 @@ describe('buildUpstreamEndpointRequest', () => { expect(request.body.prompt_cache_key).toBe('codex-cache-123'); }); + it('preserves native codex responses continuity and request fields without compatibility rewrites', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: 'hello codex', + stream: false, + store: true, + parallel_tool_calls: false, + include: ['reasoning.encrypted_content', 'mcp_approval_request.details'], + previous_response_id: 'resp_prev_123', + temperature: 0.3, + top_p: 0.8, + max_output_tokens: 512, + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-456', + } as any); + + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBe(request.headers.Session_id); + expect(request.body.prompt_cache_key).toBeUndefined(); + expect(request.body.instructions).toBe(''); + expect(request.body.stream).toBe(false); + expect(request.body.store).toBe(true); + expect(request.body.parallel_tool_calls).toBe(false); + expect(request.body.include).toEqual(['reasoning.encrypted_content', 'mcp_approval_request.details']); + expect(request.body.previous_response_id).toBe('resp_prev_123'); + expect(request.body.temperature).toBe(0.3); + expect(request.body.top_p).toBe(0.8); + expect(request.body.max_output_tokens).toBe(512); + }); + it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => { (config as any).codexHeaderDefaults = { userAgent: 'codex-config-ua/1.0', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index ea68eeb..53387b2 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -413,12 +413,7 @@ function ensureCodexResponsesStoreFalse( body: Record, sitePlatform: string, ): Record { - if (sitePlatform !== 'codex') return body; - if (body.store === false) return body; - return { - ...body, - store: false, - }; + return body; } function convertCodexSystemRoleToDeveloper(input: unknown): unknown { @@ -437,18 +432,11 @@ function convertCodexSystemRoleToDeveloper(input: unknown): unknown { function applyCodexResponsesCompatibility( body: Record, sitePlatform: string, - options?: { - preservePreviousResponseId?: boolean; - }, ): Record { if (sitePlatform !== 'codex') return body; const next: Record = { ...body, - stream: true, - store: false, - parallel_tool_calls: true, - include: ['reasoning.encrypted_content'], input: convertCodexSystemRoleToDeveloper(body.input), }; @@ -456,27 +444,6 @@ function applyCodexResponsesCompatibility( next.instructions = ''; } - for (const key of [ - 'max_output_tokens', - 'max_completion_tokens', - 'temperature', - 'top_p', - 'truncation', - 'user', - 'context_management', - 'prompt_cache_retention', - 'safety_identifier', - ]) { - delete next[key]; - } - if (!options?.preservePreviousResponseId) { - delete next.previous_response_id; - } - - if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') { - delete next.service_tier; - } - return next; } @@ -1284,7 +1251,6 @@ export function buildUpstreamEndpointRequest(input: { applyCodexResponsesCompatibility( sanitizedResponsesBody, sitePlatform, - { preservePreviousResponseId: preserveWebsocketIncrementalMode }, ), sitePlatform, ), diff --git a/src/server/transformers/anthropic/messages/conversion.test.ts b/src/server/transformers/anthropic/messages/conversion.test.ts index 03f0e03..215f3aa 100644 --- a/src/server/transformers/anthropic/messages/conversion.test.ts +++ b/src/server/transformers/anthropic/messages/conversion.test.ts @@ -562,6 +562,53 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { ]); }); + it('preserves image blocks inside tool_result content arrays', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'text', text: 'found 1 image' }, + { + type: 'image_url', + image_url: 'https://example.com/cat.png', + }, + ], + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'text', text: 'found 1 image' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + }, + }, + ], + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + it('adds a second message cache anchor around the 20-block window for long prompts', () => { const longContent = Array.from({ length: 25 }, (_, index) => ({ type: 'text', @@ -603,6 +650,57 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { expect(body.output_config).toEqual({ effort: 'high' }); }); + it('copies metadata.user_id into the anthropic payload', () => { + const metadata = { user_id: 'anthropic-user-1', trace_id: 'trace-xyz' }; + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + metadata, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.metadata).toEqual(metadata); + }); + + it('marks tool_choice.disable_parallel_tool_use when parallel_tool_calls is false', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + parallel_tool_calls: false, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.tool_choice).toMatchObject({ + type: 'auto', + disable_parallel_tool_use: true, + }); + }); + + it('augments explicit tool_choice with disable_parallel_tool_use when parallel_tool_calls is false', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + parallel_tool_calls: false, + tool_choice: { type: 'tool', name: 'search' }, + messages: [{ role: 'user', content: 'hello' }], + }, + 'claude-opus-4-6', + true, + ); + + expect(body.tool_choice).toMatchObject({ + type: 'tool', + name: 'search', + disable_parallel_tool_use: true, + }); + }); + it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => { const result = anthropicMessagesInbound.parse( { diff --git a/src/server/transformers/anthropic/messages/conversion.ts b/src/server/transformers/anthropic/messages/conversion.ts index c71be7b..89dbade 100644 --- a/src/server/transformers/anthropic/messages/conversion.ts +++ b/src/server/transformers/anthropic/messages/conversion.ts @@ -29,6 +29,14 @@ function safeJsonStringify(value: unknown): string { } } +function cloneJsonValue(value: T): T | undefined { + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return undefined; + } +} + function parseJsonString(raw: string): unknown { const trimmed = raw.trim(); if (!trimmed) return {}; @@ -187,6 +195,16 @@ function normalizeTextCandidate(value: unknown): string { return asTrimmedString(value.text ?? value.content ?? value.output_text); } +function ensureAnthropicToolChoiceRecord(value: unknown): Record | undefined { + if (isRecord(value)) return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + return { type: normalized }; + } + return undefined; +} + function sanitizeToolReferenceBlock(item: Record): Record | null { const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name); if (!toolName) return null; @@ -215,6 +233,13 @@ function normalizeToolMessageContent(raw: unknown): string | Array => item !== null); @@ -232,6 +257,17 @@ function normalizeToolMessageContent(raw: unknown): string | Array 0) { body.system = systemContents.join('\n\n'); } @@ -956,8 +1000,17 @@ export function convertOpenAiBodyToAnthropicMessagesBody( if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools); - const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); - if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice; + const parallelToolCalls = openaiBody.parallel_tool_calls; + let anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); + if (parallelToolCalls === false) { + const choiceRecord = ensureAnthropicToolChoiceRecord(anthropicToolChoice) ?? { type: 'auto' }; + choiceRecord.disable_parallel_tool_use = true; + anthropicToolChoice = choiceRecord; + } + + if (anthropicToolChoice !== undefined) { + body.tool_choice = anthropicToolChoice; + } const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody); if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking; diff --git a/src/server/transformers/canonical/request.test.ts b/src/server/transformers/canonical/request.test.ts index f265cbf..0d810fb 100644 --- a/src/server/transformers/canonical/request.test.ts +++ b/src/server/transformers/canonical/request.test.ts @@ -214,4 +214,162 @@ describe('canonical request helpers', () => { include: ['reasoning.encrypted_content', 'message.input_image.image_url'], }); }); + + it('preserves extra fields on tool-shaped raw tool_choice objects', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + tool_choice: { + type: 'tool', + name: 'browser', + mode: 'required', + disable_parallel_tool_use: true, + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.tool_choice).toEqual({ + type: 'tool', + name: 'browser', + mode: 'required', + disable_parallel_tool_use: true, + }); + }); + + it('preserves structured tool outputs and top-level attachments through canonical round-trips', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5', + surface: 'openai-chat', + attachments: [{ + kind: 'file', + fileId: 'file-top-level', + }], + messages: [{ + role: 'tool', + parts: [{ + type: 'tool_result', + toolCallId: 'call_1', + resultContent: [ + { type: 'text', text: 'tool result' }, + { type: 'image_url', image_url: { url: 'https://example.com/tool.png' } }, + ], + } as any], + }], + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.attachments).toEqual([{ + kind: 'file', + fileId: 'file-top-level', + }]); + expect(body.messages).toEqual([{ + role: 'tool', + tool_call_id: 'call_1', + content: [ + { type: 'text', text: 'tool result' }, + { type: 'image_url', image_url: { url: 'https://example.com/tool.png' } }, + ], + }]); + }); + + it('preserves richer Responses tools, raw tool_choice, assistant phase, and reasoning signatures through canonical round-trips', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + parallel_tool_calls: false, + tools: [ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + }, + ], + tool_choice: { + type: 'allowed_tools', + mode: 'auto', + tools: [{ type: 'custom', name: 'browser' }], + }, + messages: [ + { + role: 'assistant', + phase: 'analysis', + reasoning_signature: 'sig_123', + content: 'thinking', + }, + { + role: 'user', + content: 'hello', + }, + ], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.parallel_tool_calls).toBe(false); + expect(body.tools).toEqual([ + { + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + { + type: 'image_generation', + background: 'transparent', + }, + ]); + expect(body.tool_choice).toEqual({ + type: 'allowed_tools', + mode: 'auto', + tools: [{ type: 'custom', name: 'browser' }], + }); + expect(body.messages).toMatchObject([ + { + role: 'assistant', + phase: 'analysis', + reasoning_signature: 'sig_123', + content: 'thinking', + }, + { + role: 'user', + content: 'hello', + }, + ]); + }); + + it('writes raw canonical tool types back into OpenAI-compatible bodies when the raw payload omits the discriminator', () => { + const request = createCanonicalRequestEnvelope({ + requestedModel: 'gpt-5', + surface: 'openai-responses', + tools: [{ + type: 'custom', + raw: { + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }, + }], + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body.tools).toEqual([{ + type: 'custom', + name: 'browser', + description: 'browse the web', + format: { type: 'text' }, + }]); + }); }); diff --git a/src/server/transformers/canonical/request.ts b/src/server/transformers/canonical/request.ts index 92aec80..701a667 100644 --- a/src/server/transformers/canonical/request.ts +++ b/src/server/transformers/canonical/request.ts @@ -104,6 +104,9 @@ export function createCanonicalRequestEnvelope( : {}), ...(input.metadata ? { metadata: input.metadata } : {}), ...(input.passthrough ? { passthrough: input.passthrough } : {}), + ...(Array.isArray(input.attachments) && input.attachments.length > 0 + ? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] } + : {}), }; } @@ -190,7 +193,7 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine const normalized = rawToolChoice.trim().toLowerCase(); if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; if (normalized === 'any') return 'required'; - return undefined; + return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined; } if (!isRecord(rawToolChoice)) return undefined; @@ -204,20 +207,28 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine ); return name ? { type: 'tool', name } : undefined; } - if (type !== 'tool') return undefined; + if (type && type !== 'tool') { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } const name = asTrimmedString( rawToolChoice.name ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), ); - return name ? { type: 'tool', name } : undefined; + const toolChoiceKeys = Object.keys(rawToolChoice); + const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool'); + if (hasExtraToolFields) { + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; + } + if (name) return { type: 'tool', name }; + return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record }; } function parseTools(rawTools: unknown): CanonicalTool[] | undefined { if (!Array.isArray(rawTools)) return undefined; - const tools = rawTools - .flatMap((item) => { + const tools: CanonicalTool[] = rawTools + .flatMap((item): CanonicalTool[] => { if (!isRecord(item)) return []; const itemType = asTrimmedString(item.type).toLowerCase(); @@ -264,6 +275,13 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined { }); } + if (itemType) { + return [{ + type: itemType, + raw: cloneJsonValue(item) as Record, + }]; + } + return []; }); @@ -277,6 +295,9 @@ export function canonicalRequestFromOpenAiBody( const metadata = isRecord(input.metadata) ? input.metadata : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); + const attachments = Array.isArray(body.attachments) + ? cloneJsonValue(body.attachments) as CanonicalAttachment[] + : undefined; const messages: CanonicalMessage[] = []; const rawMessages = Array.isArray(body.messages) ? body.messages : []; @@ -286,15 +307,21 @@ export function canonicalRequestFromOpenAiBody( if (role === 'tool') { const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); - const resultText = typeof rawMessage.content === 'string' - ? rawMessage.content - : safeJsonStringify(rawMessage.content ?? ''); + const rawContent = rawMessage.content; + const resultText = typeof rawContent === 'string' + ? rawContent + : (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : ''); messages.push({ role: 'tool', parts: [{ type: 'tool_result', toolCallId: toolCallId || 'tool', ...(resultText ? { resultText } : {}), + ...(Array.isArray(rawContent) + ? { resultContent: cloneJsonValue(rawContent) as Array> } + : (isRecord(rawContent) + ? { resultContent: [cloneJsonValue(rawContent) as Record] } + : {})), }], }); continue; @@ -322,6 +349,10 @@ export function canonicalRequestFromOpenAiBody( messages.push({ role, parts, + ...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}), + ...(asTrimmedString(rawMessage.reasoning_signature) + ? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) } + : {}), }); } @@ -343,6 +374,14 @@ export function canonicalRequestFromOpenAiBody( : {}), }; + const passthrough = { + ...(input.passthrough ?? {}), + ...(typeof body.parallel_tool_calls === 'boolean' + ? { parallel_tool_calls: body.parallel_tool_calls } + : {}), + ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), + }; + return createCanonicalRequestEnvelope({ operation: input.operation ?? 'generate', surface: input.surface, @@ -355,14 +394,8 @@ export function canonicalRequestFromOpenAiBody( ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), ...(Object.keys(continuation).length > 0 ? { continuation } : {}), ...(metadata ? { metadata } : {}), - ...((reasoningResult.metadata || input.passthrough) - ? { - passthrough: { - ...(input.passthrough ?? {}), - ...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}), - }, - } - : {}), + ...(attachments ? { attachments } : {}), + ...(Object.keys(passthrough).length > 0 ? { passthrough } : {}), }); } @@ -418,7 +451,10 @@ function canonicalPartsToOpenAiContent( continue; } if (part.type === 'tool_result' && role !== 'tool') { - const text = part.resultText ?? safeJsonStringify(part.resultJson ?? ''); + const text = part.resultText + ?? (typeof part.resultContent === 'string' + ? part.resultContent + : safeJsonStringify(part.resultJson ?? part.resultContent ?? '')); if (text) { visibleText.push(text); } @@ -451,6 +487,7 @@ function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined if (!toolChoice) return undefined; if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; if (toolChoice === 'required') return 'required'; + if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value); return { type: 'function', function: { @@ -471,7 +508,9 @@ export function canonicalRequestToOpenAiChatBody( messages.push({ role: 'tool', tool_call_id: part.toolCallId, - content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''), + content: part.resultContent + ?? part.resultText + ?? safeJsonStringify(part.resultJson ?? ''), }); } continue; @@ -485,6 +524,8 @@ export function canonicalRequestToOpenAiChatBody( if (message.role === 'assistant' && converted.reasoning) { nextMessage.reasoning_content = converted.reasoning; } + if (message.phase) nextMessage.phase = message.phase; + if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature; if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { nextMessage.tool_calls = converted.toolCalls; if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array).length <= 0) { @@ -517,18 +558,30 @@ export function canonicalRequestToOpenAiChatBody( ].filter((item, index, all) => all.indexOf(item) === index); if (mergedInclude.length > 0) body.include = mergedInclude; if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata); + if (Array.isArray(request.attachments) && request.attachments.length > 0) { + body.attachments = cloneJsonValue(request.attachments); + } if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; if (Array.isArray(request.tools) && request.tools.length > 0) { - body.tools = request.tools.map((tool) => ({ - type: 'function', - function: { - name: tool.name, - ...(tool.description ? { description: tool.description } : {}), - ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), - parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), - }, - })); + body.tools = request.tools.map((tool) => { + if ('raw' in tool) { + const raw = cloneJsonValue(tool.raw) as Record; + if (typeof raw.type !== 'string' || raw.type.trim().length === 0) { + raw.type = tool.type; + } + return raw; + } + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), + parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), + }, + }; + }); } const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); if (toolChoice !== undefined) body.tool_choice = toolChoice; diff --git a/src/server/transformers/canonical/tools.ts b/src/server/transformers/canonical/tools.ts index 9600d18..4c85efc 100644 --- a/src/server/transformers/canonical/tools.ts +++ b/src/server/transformers/canonical/tools.ts @@ -1,10 +1,19 @@ -export type CanonicalTool = { +export type CanonicalFunctionTool = { name: string; description?: string; strict?: boolean; inputSchema?: Record | null; }; +export type CanonicalRawTool = { + type: string; + raw: Record; +}; + +export type CanonicalTool = + | CanonicalFunctionTool + | CanonicalRawTool; + export type CanonicalToolChoice = | 'auto' | 'none' @@ -12,4 +21,18 @@ export type CanonicalToolChoice = | { type: 'tool'; name: string; + } + | { + type: 'raw'; + value: string | Record; }; + +export function isCanonicalFunctionTool(tool: CanonicalTool): tool is CanonicalFunctionTool { + return 'name' in tool; +} + +export function isCanonicalNamedToolChoice( + toolChoice: CanonicalToolChoice | undefined, +): toolChoice is { type: 'tool'; name: string } { + return !!toolChoice && typeof toolChoice === 'object' && toolChoice.type === 'tool'; +} diff --git a/src/server/transformers/canonical/types.ts b/src/server/transformers/canonical/types.ts index 5577808..0611d83 100644 --- a/src/server/transformers/canonical/types.ts +++ b/src/server/transformers/canonical/types.ts @@ -1,3 +1,4 @@ +import type { CanonicalAttachment } from './attachments.js'; import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; export type CanonicalOperation = @@ -57,6 +58,7 @@ export type CanonicalToolResultPart = { toolCallId: string; resultText?: string; resultJson?: unknown; + resultContent?: string | Array>; }; export type CanonicalContentPart = @@ -69,6 +71,8 @@ export type CanonicalContentPart = export type CanonicalMessage = { role: CanonicalMessageRole; parts: CanonicalContentPart[]; + phase?: string; + reasoningSignature?: string; }; export type CanonicalReasoningEffort = @@ -105,4 +109,5 @@ export type CanonicalRequestEnvelope = { continuation?: CanonicalContinuation; metadata?: Record; passthrough?: Record; + attachments?: CanonicalAttachment[]; }; diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 9661f88..92ef4f5 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,4 +1,8 @@ -import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js'; +import { + canonicalRequestFromOpenAiBody, + isCanonicalFunctionTool, + isCanonicalNamedToolChoice, +} from '../../canonical/request.js'; import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import { @@ -218,13 +222,16 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec } if (Array.isArray(request.tools) && request.tools.length > 0) { - payload.tools = [{ - functionDeclarations: request.tools.map((tool) => ({ - name: tool.name, - ...(tool.description ? { description: tool.description } : {}), - ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), - })), - }]; + const functionTools = request.tools.filter(isCanonicalFunctionTool); + if (functionTools.length > 0) { + payload.tools = [{ + functionDeclarations: functionTools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), + })), + }]; + } } if (request.toolChoice) { @@ -234,7 +241,7 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; } else if (request.toolChoice === 'required') { payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; - } else { + } else if (isCanonicalNamedToolChoice(request.toolChoice)) { payload.toolConfig = { functionCallingConfig: { mode: 'ANY', diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index f05accb..e30696a 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -683,6 +683,86 @@ describe('convertOpenAiBodyToResponsesBody', () => { ]); }); + it('preserves structured tool outputs and assistant phase when converting Responses bodies to OpenAI-compatible messages', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: [ + { + type: 'function_call', + call_id: 'call_1', + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: [ + { + type: 'output_text', + text: 'tool result', + }, + { + type: 'input_image', + image_url: 'https://example.com/tool.png', + }, + ], + }, + { + type: 'message', + role: 'assistant', + phase: 'analysis', + content: [ + { + type: 'output_text', + text: 'done', + }, + ], + }, + ], + }, + 'gpt-5', + false, + ); + + expect(result.messages).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'lookup_weather', + arguments: '{"city":"Shanghai"}', + }, + }], + }, + { + role: 'tool', + tool_call_id: 'call_1', + content: [ + { + type: 'text', + text: 'tool result', + }, + { + type: 'image_url', + image_url: 'https://example.com/tool.png', + }, + ], + }, + { + role: 'assistant', + phase: 'analysis', + content: [{ + type: 'text', + text: 'done', + }], + }, + ]); + }); + it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => { const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening'; const firstName = `mcp__alpha_workspace__${sharedSuffix}`; @@ -944,6 +1024,65 @@ describe('convertResponsesBodyToOpenAiBody', () => { ]); }); + it('round-trips mcp item families through OpenAI-compatible fallback using compatibility tool calls', () => { + const source = { + model: 'gpt-5', + input: [ + { + type: 'mcp_call', + id: 'mcp_call_1', + call_id: 'mcp_call_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }, + { + type: 'mcp_approval_request', + id: 'mcp_approval_request_1', + approval_request_id: 'mcp_approval_request_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }, + { + type: 'mcp_approval_response', + id: 'mcp_approval_response_1', + approval_request_id: 'mcp_approval_request_1', + approve: true, + }, + ], + }; + + const openAiBody = convertResponsesBodyToOpenAiBody( + source, + 'gpt-5', + false, + ); + + expect(openAiBody.messages).toHaveLength(1); + expect(openAiBody.messages[0]).toMatchObject({ + role: 'assistant', + content: '', + tool_calls: [ + { id: 'mcp_call_1', type: 'function' }, + { id: 'mcp_approval_request_1', type: 'function' }, + { id: 'mcp_approval_response_1', type: 'function' }, + ], + }); + + const roundTripped = convertOpenAiBodyToResponsesBody( + openAiBody, + 'gpt-5', + false, + ); + + expect(roundTripped.input).toEqual(source.input); + }); + it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => { const result = convertResponsesBodyToOpenAiBody( { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index ed16d4d..4968075 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -3,6 +3,11 @@ import { normalizeResponsesMessageContentBlocks, normalizeResponsesMessageItem, } from './normalization.js'; +import { + decodeResponsesMcpCompatToolCall, + isResponsesMcpItem, + toResponsesMcpCompatToolCall, +} from './mcpCompatibility.js'; import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js'; import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js'; @@ -241,9 +246,12 @@ function normalizeOpenAiToolArguments(raw: unknown): string { return ''; } -function normalizeToolOutput(raw: unknown): string { - const text = extractTextContent(raw).trim(); - if (text) return text; +function normalizeToolOutput(raw: unknown): string | Array> { + const normalizedContent = toOpenAiMessageContent(raw); + const hasNormalizedContent = typeof normalizedContent === 'string' + ? normalizedContent.trim().length > 0 + : Array.isArray(normalizedContent) && normalizedContent.length > 0; + if (hasNormalizedContent) return normalizedContent; if (raw === undefined || raw === null) return ''; if (typeof raw === 'string') return raw; if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); @@ -498,6 +506,14 @@ export function convertOpenAiBodyToResponsesBody( const toolCall = rawToolCalls[index]; if (!isRecord(toolCall)) continue; const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; + const mcpItem = decodeResponsesMcpCompatToolCall( + functionPart.name ?? toolCall.name, + functionPart.arguments ?? toolCall.arguments, + ); + if (mcpItem) { + inputItems.push(mcpItem); + continue; + } const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; const name = ( asTrimmedString(functionPart.name) @@ -810,6 +826,15 @@ export function convertResponsesBodyToOpenAiBody( if (!isRecord(item)) return; const itemType = asTrimmedString(item.type).toLowerCase(); + if (itemType.startsWith('mcp_') && isResponsesMcpItem(item)) { + const toolCall = toResponsesMcpCompatToolCall(item, `call_${Date.now()}_${functionCallIndex}`); + if (toolCall) { + pendingToolCalls.push(toolCall as OpenAiToolCall); + functionCallIndex += 1; + return; + } + } + if (itemType === 'function_call' || itemType === 'custom_tool_call') { const toolCall = toOpenAiToolCall(item, functionCallIndex); functionCallIndex += 1; @@ -858,10 +883,13 @@ export function convertResponsesBodyToOpenAiBody( : Array.isArray(content) && content.length > 0; if (!hasContent) return; - messages.push({ + const message: Record = { role: normalizedRole, content, - }); + }; + const phase = asTrimmedString(item.phase); + if (phase) message.phase = phase; + messages.push(message); }; if (typeof input === 'string') { diff --git a/src/server/transformers/openai/responses/mcpCompatibility.ts b/src/server/transformers/openai/responses/mcpCompatibility.ts new file mode 100644 index 0000000..3c5357e --- /dev/null +++ b/src/server/transformers/openai/responses/mcpCompatibility.ts @@ -0,0 +1,110 @@ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +const RESPONSES_MCP_COMPAT_MARKER = 'responses_mcp_item'; +const RESPONSES_MCP_COMPAT_PREFIX = 'metapi_mcp_item__'; + +type ResponsesMcpCompatEnvelope = { + metapi_compat: typeof RESPONSES_MCP_COMPAT_MARKER; + itemType: string; + item: Record; +}; + +export function isResponsesMcpItem(item: unknown): item is Record { + return isRecord(item) && asTrimmedString(item.type).toLowerCase().startsWith('mcp_'); +} + +function createResponsesMcpCompatEnvelope( + item: Record, +): ResponsesMcpCompatEnvelope | null { + const itemType = asTrimmedString(item.type).toLowerCase(); + if (!itemType.startsWith('mcp_')) return null; + + return { + metapi_compat: RESPONSES_MCP_COMPAT_MARKER, + itemType, + item: cloneJsonValue(item), + }; +} + +export function toResponsesMcpCompatToolName(itemType: string): string { + const normalizedType = asTrimmedString(itemType).toLowerCase(); + return `${RESPONSES_MCP_COMPAT_PREFIX}${normalizedType || 'item'}`; +} + +export function toResponsesMcpCompatToolCall( + item: Record, + fallbackId: string, +): Record | null { + const envelope = createResponsesMcpCompatEnvelope(item); + if (!envelope) return null; + + const id = ( + asTrimmedString(item.call_id) + || asTrimmedString(item.id) + || asTrimmedString(item.approval_request_id) + || fallbackId + ); + + return { + id, + type: 'function', + function: { + name: toResponsesMcpCompatToolName(envelope.itemType), + arguments: JSON.stringify(envelope), + }, + }; +} + +function parseCompatEnvelope(rawArguments: unknown): ResponsesMcpCompatEnvelope | null { + if (typeof rawArguments !== 'string') return null; + const trimmed = rawArguments.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!isRecord(parsed)) return null; + if (asTrimmedString(parsed.metapi_compat) !== RESPONSES_MCP_COMPAT_MARKER) return null; + + const itemType = asTrimmedString(parsed.itemType).toLowerCase(); + if (!itemType.startsWith('mcp_')) return null; + if (!isRecord(parsed.item)) return null; + + return { + metapi_compat: RESPONSES_MCP_COMPAT_MARKER, + itemType, + item: cloneJsonValue(parsed.item), + }; + } catch { + return null; + } +} + +export function decodeResponsesMcpCompatToolCall( + toolName: unknown, + rawArguments: unknown, +): Record | null { + const normalizedName = asTrimmedString(toolName).toLowerCase(); + if (!normalizedName.startsWith(RESPONSES_MCP_COMPAT_PREFIX)) return null; + + const envelope = parseCompatEnvelope(rawArguments); + if (!envelope) return null; + return envelope.item; +} diff --git a/src/server/transformers/openai/responses/outbound.test.ts b/src/server/transformers/openai/responses/outbound.test.ts index 9a6a9da..9a8a830 100644 --- a/src/server/transformers/openai/responses/outbound.test.ts +++ b/src/server/transformers/openai/responses/outbound.test.ts @@ -254,6 +254,52 @@ describe('serializeResponsesFinalPayload', () => { ]); }); + it('restores mcp items from compatibility tool calls when serializing fallback response payloads', () => { + const mcpCall = { + type: 'mcp_call', + id: 'mcp_call_1', + call_id: 'mcp_call_1', + name: 'read_file', + server_label: 'filesystem', + arguments: { + path: '/tmp/demo.txt', + }, + }; + + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'opaque_mcp_1', + model: 'gpt-5', + }, + normalized: { + id: 'opaque_mcp_1', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [ + { + id: 'mcp_call_1', + name: 'metapi_mcp_item__mcp_call', + arguments: JSON.stringify({ + metapi_compat: 'responses_mcp_item', + itemType: 'mcp_call', + item: mcpCall, + }), + }, + ], + }, + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }); + + expect(payload.output).toEqual([mcpCall]); + }); + it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => { const payload = serializeResponsesFinalPayload({ upstreamPayload: { diff --git a/src/server/transformers/openai/responses/outbound.ts b/src/server/transformers/openai/responses/outbound.ts index 37af024..07e34d2 100644 --- a/src/server/transformers/openai/responses/outbound.ts +++ b/src/server/transformers/openai/responses/outbound.ts @@ -3,6 +3,7 @@ import { type NormalizedFinalResponse, } from '../../shared/normalized.js'; import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js'; +import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -228,6 +229,10 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut }; } + if (itemType.startsWith('mcp_')) { + return cloneJson(rawItem); + } + return null; }) .filter((item): item is ResponsesOutputItem => item !== null); @@ -394,6 +399,12 @@ export function serializeResponsesFinalPayload(input: { if (toolCalls.length > 0 && !hasToolLikeItem) { for (const toolCall of toolCalls) { + const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments); + if (mcpItem) { + output.push(mcpItem); + continue; + } + output.push({ id: toolCall.id, type: 'function_call', diff --git a/src/server/transformers/shared/chatFormatsCore.test.ts b/src/server/transformers/shared/chatFormatsCore.test.ts index 5f25ad3..75ec573 100644 --- a/src/server/transformers/shared/chatFormatsCore.test.ts +++ b/src/server/transformers/shared/chatFormatsCore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js'; +import { createStreamTransformContext, normalizeUpstreamStreamEvent, convertClaudeRequestToOpenAiBody } from './chatFormatsCore.js'; describe('chatFormatsCore inline think parsing', () => { it('tracks split think tags across stream chunks', () => { @@ -67,3 +67,50 @@ describe('chatFormatsCore inline think parsing', () => { }); }); }); + +describe('convertClaudeRequestToOpenAiBody', () => { + it('keeps Claude tool_result content structured when a tool produces image blocks', () => { + const payload = { + model: 'gpt-test', + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-image', + name: 'ImageTool', + input: { query: 'cat' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-image', + content: [ + { type: 'text', text: 'found 1' }, + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/cat.png', + media_type: 'image/png', + }, + }, + ], + }, + ], + }, + ], + }; + + const { messages } = convertClaudeRequestToOpenAiBody(payload); + const toolMessage = messages.find((message) => message.role === 'tool'); + expect(toolMessage).toBeTruthy(); + expect(Array.isArray(toolMessage?.content)).toBe(true); + expect(toolMessage?.content.some((part: any) => part?.type === 'image_url')).toBe(true); + }); +}); diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index 97b0645..588e88f 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -778,7 +778,7 @@ function extractClaudeReasoningRequest( }; } -function convertClaudeRequestToOpenAiBody(body: Record): { +export function convertClaudeRequestToOpenAiBody(body: Record): { model: string; stream: boolean; messages: Array>; @@ -789,6 +789,42 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const messages: Array> = []; + const convertToolResultContent = (content: unknown): string | Array> | null => { + const blocks: Array> = []; + + const appendContentBlock = (item: unknown) => { + if (isRecord(item)) { + const block = toOpenAiContentBlockFromClaudeBlock(item); + if (block) { + blocks.push(block); + return; + } + } + const text = parseClaudeMessageContent(item); + if (text) { + blocks.push({ type: 'text', text }); + } + }; + + const valueToProcess = (isRecord(content) && Array.isArray(content.content)) + ? (content.content as unknown[]) + : content; + + if (Array.isArray(valueToProcess)) { + for (const item of valueToProcess) { + appendContentBlock(item); + } + } else { + appendContentBlock(valueToProcess); + } + + if (blocks.length <= 0) return null; + const collapsed = collapseOpenAiContentBlocks(blocks); + if (!collapsed) return null; + if (typeof collapsed === 'string' && collapsed.length === 0) return null; + return collapsed; + }; + const appendMessage = (role: string, content: unknown) => { const text = parseClaudeMessageContent(content); if (!text) return; @@ -799,16 +835,13 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : ''; if (!toolCallId) return; - const text = ( - parseClaudeMessageContent(content) - || stringifyUnknownValue(content) - ).trim(); - if (!text) return; + const contentPayload = convertToolResultContent(content); + if (!contentPayload) return; messages.push({ role: 'tool', tool_call_id: toolCallId, - content: text, + content: contentPayload, }); };