From 7e09ce83b80a24359b365574a6af443b94e58ced Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Sat, 21 Mar 2026 16:38:46 +0800 Subject: [PATCH] fix(proxy): strip responses-only tools on chat fallback --- .../routes/proxy/upstreamEndpoint.test.ts | 73 +++++++++++ src/server/routes/proxy/upstreamEndpoint.ts | 116 +++++++++++++++++- 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 56971bf..7ca1a04 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -1803,6 +1803,79 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('drops Responses-only tools when /v1/responses falls back to /v1/chat/completions', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'sk-test', + sitePlatform: 'openai', + siteUrl: 'https://example.com', + downstreamFormat: 'responses', + openaiBody: { + model: 'gpt-5.4', + messages: [ + { + role: 'user', + content: 'summarize the workspace state', + }, + ], + tools: [ + { + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }, + { + type: 'custom', + name: 'browser', + format: { type: 'text' }, + }, + { + type: 'image_generation', + size: '1024x1024', + }, + ], + tool_choice: { + type: 'custom', + name: 'browser', + }, + }, + }); + + expect(request.path).toBe('/v1/chat/completions'); + expect(request.body).toMatchObject({ + model: 'gpt-5.4', + stream: false, + tools: [ + { + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }, + ], + }); + expect(request.body.tool_choice).toBeUndefined(); + }); + it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'messages', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 1aa0484..e388cdd 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -471,6 +471,106 @@ function ensureStreamAcceptHeader( }; } +function normalizeResponsesFallbackChatFunctionTool(rawTool: unknown): Record | null { + if (!isRecord(rawTool)) return null; + if (asTrimmedString(rawTool.type).toLowerCase() !== 'function') return null; + + if (isRecord(rawTool.function)) { + const name = asTrimmedString(rawTool.function.name); + if (!name) return null; + return { + ...rawTool, + type: 'function', + function: { + ...rawTool.function, + name, + }, + }; + } + + const name = asTrimmedString(rawTool.name); + if (!name) return null; + + const fn: Record = { name }; + const description = asTrimmedString(rawTool.description); + if (description) fn.description = description; + if (rawTool.parameters !== undefined) fn.parameters = rawTool.parameters; + if (rawTool.strict !== undefined) fn.strict = rawTool.strict; + + return { + type: 'function', + function: fn, + }; +} + +function normalizeResponsesFallbackChatToolChoice( + rawToolChoice: unknown, + allowedToolNames: Set, +): unknown { + if (rawToolChoice === undefined) return undefined; + + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'none') return 'none'; + if (allowedToolNames.size <= 0) return undefined; + if (normalized === 'auto' || normalized === 'required') return normalized; + return undefined; + } + + if (!isRecord(rawToolChoice)) return undefined; + if (asTrimmedString(rawToolChoice.type).toLowerCase() !== 'function') return undefined; + + const nestedFunction = isRecord(rawToolChoice.function) ? rawToolChoice.function : null; + const name = asTrimmedString(nestedFunction?.name ?? rawToolChoice.name); + if (!name || !allowedToolNames.has(name)) return undefined; + + return { + type: 'function', + function: { + ...(nestedFunction || {}), + name, + }, + }; +} + +function sanitizeResponsesFallbackChatBody( + body: Record, +): Record { + const next: Record = { ...body }; + const normalizedTools = Array.isArray(body.tools) + ? body.tools + .map((tool) => normalizeResponsesFallbackChatFunctionTool(tool)) + .filter((tool): tool is Record => !!tool) + : []; + + if (normalizedTools.length > 0) { + next.tools = normalizedTools; + } else { + delete next.tools; + } + + const allowedToolNames = new Set( + normalizedTools + .map((tool) => ( + isRecord(tool.function) + ? asTrimmedString(tool.function.name) + : '' + )) + .filter((name) => name.length > 0), + ); + const normalizedToolChoice = normalizeResponsesFallbackChatToolChoice( + body.tool_choice, + allowedToolNames, + ); + if (normalizedToolChoice !== undefined) { + next.tool_choice = normalizedToolChoice; + } else { + delete next.tool_choice; + } + + return next; +} + function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } @@ -1374,14 +1474,20 @@ export function buildUpstreamEndpointRequest(input: { } const headers = ensureStreamAcceptHeader(commonHeaders, input.stream); + const chatBody = { + ...openaiBody, + model: input.modelName, + stream: input.stream, + }; + const configuredChatBody = applyConfiguredPayloadRules( + input.downstreamFormat === 'responses' + ? sanitizeResponsesFallbackChatBody(chatBody) + : chatBody, + ); return { path: resolveEndpointPath('chat'), headers, - body: applyConfiguredPayloadRules({ - ...openaiBody, - model: input.modelName, - stream: input.stream, - }), + body: configuredChatBody, runtime, }; }