forked from boos/BoosAPI
fix(proxy): 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', () => {
|
it('preserves Anthropic image and tool_result blocks instead of flattening to plain text', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'messages',
|
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 {
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user