fix(proxy): strip responses-only tools on chat fallback

This commit is contained in:
ciat-777
2026-03-21 16:38:46 +08:00
parent f586d3f318
commit 7e09ce83b8
2 changed files with 184 additions and 5 deletions
@@ -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', () => { it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => {
const request = buildUpstreamEndpointRequest({ const request = buildUpstreamEndpointRequest({
endpoint: 'messages', endpoint: 'messages',
+111 -5
View File
@@ -471,6 +471,106 @@ function ensureStreamAcceptHeader(
}; };
} }
function normalizeResponsesFallbackChatFunctionTool(rawTool: unknown): Record<string, unknown> | 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<string, unknown> = { 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<string>,
): 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<string, unknown>,
): Record<string, unknown> {
const next: Record<string, unknown> = { ...body };
const normalizedTools = Array.isArray(body.tools)
? body.tools
.map((tool) => normalizeResponsesFallbackChatFunctionTool(tool))
.filter((tool): tool is Record<string, unknown> => !!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 { function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : 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 headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
return { const chatBody = {
path: resolveEndpointPath('chat'),
headers,
body: applyConfiguredPayloadRules({
...openaiBody, ...openaiBody,
model: input.modelName, model: input.modelName,
stream: input.stream, stream: input.stream,
}), };
const configuredChatBody = applyConfiguredPayloadRules(
input.downstreamFormat === 'responses'
? sanitizeResponsesFallbackChatBody(chatBody)
: chatBody,
);
return {
path: resolveEndpointPath('chat'),
headers,
body: configuredChatBody,
runtime, runtime,
}; };
} }