Merge pull request #231 from cita-777/codex/responses-chat-tool-filter
fix: strip responses-only tools on chat fallback
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -1374,14 +1474,20 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
}
|
||||
|
||||
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
|
||||
return {
|
||||
path: resolveEndpointPath('chat'),
|
||||
headers,
|
||||
body: applyConfiguredPayloadRules({
|
||||
const chatBody = {
|
||||
...openaiBody,
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
}),
|
||||
};
|
||||
const configuredChatBody = applyConfiguredPayloadRules(
|
||||
input.downstreamFormat === 'responses'
|
||||
? sanitizeResponsesFallbackChatBody(chatBody)
|
||||
: chatBody,
|
||||
);
|
||||
return {
|
||||
path: resolveEndpointPath('chat'),
|
||||
headers,
|
||||
body: configuredChatBody,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user