complete codex cliproxyapi parity
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { convertOpenAiBodyToGeminiGenerateContentRequest } from './conversion.js';
|
||||
|
||||
describe('convertOpenAiBodyToGeminiGenerateContentRequest', () => {
|
||||
it('maps OpenAI generic file blocks to Gemini inlineData parts', () => {
|
||||
const request = convertOpenAiBodyToGeminiGenerateContentRequest({
|
||||
modelName: 'gemini-2.5-pro',
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'summarize this file' },
|
||||
{
|
||||
inlineData: {
|
||||
mime_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { normalizeInputFileBlock } from '../../shared/inputFile.js';
|
||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||
|
||||
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseDataUrl(url: string): { mimeType: string; data: string } | null {
|
||||
if (!url.startsWith('data:')) return null;
|
||||
const [, rest] = url.split('data:', 2);
|
||||
const [meta, data] = rest.split(',', 2);
|
||||
if (!meta || !data) return null;
|
||||
const [mimeType] = meta.split(';', 1);
|
||||
return {
|
||||
mimeType: mimeType || 'application/octet-stream',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFunctionResponseResult(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toGeminiInlineDataPart(input: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
inlineData: {
|
||||
mime_type: input.mimeType,
|
||||
data: input.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function convertContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
|
||||
if (isRecord(content)) {
|
||||
if (typeof content.text === 'string') {
|
||||
const trimmed = content.text.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const item of content) {
|
||||
if (!isRecord(item)) continue;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url' || type === 'input_image') {
|
||||
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.image_url ?? item.url);
|
||||
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
|
||||
if (parsed) {
|
||||
parts.push(toGeminiInlineDataPart(parsed));
|
||||
continue;
|
||||
}
|
||||
if (imageUrl) {
|
||||
parts.push({
|
||||
fileData: {
|
||||
fileUri: imageUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_audio') {
|
||||
const audio = isRecord(item.input_audio) ? item.input_audio : item;
|
||||
const data = asTrimmedString(audio.data);
|
||||
if (data) {
|
||||
parts.push(toGeminiInlineDataPart({
|
||||
mimeType: asTrimmedString(audio.mime_type ?? audio.mimeType) || 'audio/wav',
|
||||
data,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedFile = normalizeInputFileBlock(item);
|
||||
if (normalizedFile) {
|
||||
if (normalizedFile.fileData) {
|
||||
const parsed = parseDataUrl(normalizedFile.fileData);
|
||||
parts.push(toGeminiInlineDataPart({
|
||||
mimeType: normalizedFile.mimeType || parsed?.mimeType || 'application/octet-stream',
|
||||
data: parsed?.data || normalizedFile.fileData,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileUri = normalizedFile.fileUrl || normalizedFile.fileId;
|
||||
if (fileUri) {
|
||||
parts.push({
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(normalizedFile.mimeType ? { mimeType: normalizedFile.mimeType } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildGeminiTools(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const declarations = tools
|
||||
.filter((item) => isRecord(item))
|
||||
.flatMap((item) => {
|
||||
if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return [];
|
||||
const fn = item.function as Record<string, unknown>;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}),
|
||||
parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} },
|
||||
}];
|
||||
});
|
||||
|
||||
if (declarations.length <= 0) return undefined;
|
||||
return [{ functionDeclarations: declarations }];
|
||||
}
|
||||
|
||||
function buildGeminiToolConfig(toolChoice: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof toolChoice === 'string') {
|
||||
const normalized = toolChoice.trim().toLowerCase();
|
||||
if (normalized === 'none') {
|
||||
return { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
if (normalized === 'required') {
|
||||
return { functionCallingConfig: { mode: 'ANY' } };
|
||||
}
|
||||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function convertOpenAiBodyToGeminiGenerateContentRequest(input: {
|
||||
body: Record<string, unknown>;
|
||||
modelName: string;
|
||||
instructions?: string;
|
||||
}) {
|
||||
const request: Record<string, unknown> = {
|
||||
contents: [],
|
||||
};
|
||||
|
||||
const messages = Array.isArray(input.body.messages) ? input.body.messages : [];
|
||||
const toolNameById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (id && name) {
|
||||
toolNameById.set(id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
const thoughtSignatureById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
if (!id) continue;
|
||||
const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null;
|
||||
if (providerFields && typeof providerFields.thought_signature === 'string') {
|
||||
thoughtSignatureById.set(id, providerFields.thought_signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
if (typeof input.instructions === 'string' && input.instructions.trim()) {
|
||||
systemParts.push({ text: input.instructions.trim() });
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message)) continue;
|
||||
const role = asTrimmedString(message.role).toLowerCase();
|
||||
if (role === 'system' || role === 'developer') {
|
||||
systemParts.push(...convertContentToGeminiParts(message.content));
|
||||
continue;
|
||||
}
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(message.tool_call_id);
|
||||
const name = toolNameById.get(toolCallId) || 'unknown';
|
||||
const result = normalizeFunctionResponseResult(message.content);
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
result,
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts = convertContentToGeminiParts(message.content);
|
||||
const functionCallParts: Array<Record<string, unknown>> = [];
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (!name) continue;
|
||||
const rawArguments = toolCall.function.arguments;
|
||||
let args: unknown = {};
|
||||
if (typeof rawArguments === 'string' && rawArguments.trim()) {
|
||||
try {
|
||||
args = JSON.parse(rawArguments);
|
||||
} catch {
|
||||
args = { raw: rawArguments };
|
||||
}
|
||||
} else if (isRecord(rawArguments)) {
|
||||
args = rawArguments;
|
||||
}
|
||||
const functionCallPart: Record<string, unknown> = {
|
||||
functionCall: { name, args },
|
||||
};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const signature = thoughtSignatureById.get(id);
|
||||
if (signature) {
|
||||
functionCallPart.thoughtSignature = signature;
|
||||
} else if (hasThinkingEnabled) {
|
||||
functionCallPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
|
||||
}
|
||||
functionCallParts.push(functionCallPart);
|
||||
}
|
||||
|
||||
const geminiRole = role === 'assistant' ? 'model' : 'user';
|
||||
const hasSigned = functionCallParts.some((part) => 'thoughtSignature' in part);
|
||||
if (hasSigned && textParts.length > 0 && functionCallParts.length > 0) {
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: textParts },
|
||||
{ role: geminiRole, parts: functionCallParts },
|
||||
];
|
||||
} else {
|
||||
const allParts = [...textParts, ...functionCallParts];
|
||||
if (allParts.length <= 0) continue;
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: allParts },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
request.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
const maxOutputTokens = Number(
|
||||
input.body.max_output_tokens
|
||||
?? input.body.max_completion_tokens
|
||||
?? input.body.max_tokens
|
||||
?? 0,
|
||||
);
|
||||
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
|
||||
generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens);
|
||||
}
|
||||
const temperature = Number(input.body.temperature);
|
||||
if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
|
||||
const topP = Number(input.body.top_p);
|
||||
if (Number.isFinite(topP)) generationConfig.topP = topP;
|
||||
const topK = Number(input.body.top_k);
|
||||
if (Number.isFinite(topK)) generationConfig.topK = topK;
|
||||
if (Array.isArray(input.body.stop) && input.body.stop.length > 0) {
|
||||
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
if (thinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
request.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
const geminiTools = buildGeminiTools(input.body.tools);
|
||||
if (geminiTools) {
|
||||
request.tools = geminiTools;
|
||||
}
|
||||
const toolConfig = buildGeminiToolConfig(input.body.tool_choice);
|
||||
if (toolConfig) {
|
||||
request.toolConfig = toolConfig;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
@@ -187,6 +187,27 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves unknown top-level fields while still normalizing known compatibility fields', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
max_completion_tokens: 256,
|
||||
custom_vendor_flag: 'keep-me',
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: false,
|
||||
custom_vendor_flag: 'keep-me',
|
||||
max_output_tokens: 256,
|
||||
});
|
||||
expect(result.max_completion_tokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
@@ -432,6 +453,84 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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}`;
|
||||
const secondName = `mcp__beta_workspace__${sharedSuffix}`;
|
||||
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: firstName,
|
||||
arguments: '{"city":"shanghai"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: 'done',
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: firstName,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: secondName,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: secondName,
|
||||
},
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
const toolNames = Array.isArray(result.tools)
|
||||
? result.tools.map((tool: any) => tool.name)
|
||||
: [];
|
||||
const assistantCall = Array.isArray(result.input)
|
||||
? result.input.find((item: any) => item.type === 'function_call')
|
||||
: null;
|
||||
|
||||
expect(toolNames).toHaveLength(2);
|
||||
expect(toolNames[0].length).toBeLessThanOrEqual(64);
|
||||
expect(toolNames[1].length).toBeLessThanOrEqual(64);
|
||||
expect(toolNames[0].startsWith('mcp__')).toBe(true);
|
||||
expect(toolNames[1].startsWith('mcp__')).toBe(true);
|
||||
expect(toolNames[0]).not.toBe(toolNames[1]);
|
||||
expect(result.tool_choice).toEqual({
|
||||
type: 'function',
|
||||
name: toolNames[1],
|
||||
});
|
||||
expect(assistantCall).toMatchObject({
|
||||
type: 'function_call',
|
||||
name: toolNames[0],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
normalizeResponsesMessageItem,
|
||||
} from './compatibility.js';
|
||||
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
||||
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
@@ -247,7 +248,49 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
function collectOpenAiToolNames(body: Record<string, unknown>): string[] {
|
||||
const names: string[] = [];
|
||||
const pushName = (value: unknown) => {
|
||||
const name = asTrimmedString(value);
|
||||
if (name) names.push(name);
|
||||
};
|
||||
|
||||
const rawTools = Array.isArray(body.tools) ? body.tools : [];
|
||||
for (const item of rawTools) {
|
||||
if (!isRecord(item)) continue;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function' && isRecord(item.function)) {
|
||||
pushName(item.function.name);
|
||||
continue;
|
||||
}
|
||||
if (type === 'function') {
|
||||
pushName(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
|
||||
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
|
||||
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
for (const message of rawMessages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const functionPart = isRecord(toolCall.function) ? toolCall.function : null;
|
||||
pushName(functionPart?.name ?? toolCall.name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function convertOpenAiToolsToResponses(
|
||||
rawTools: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
const converted = rawTools
|
||||
@@ -262,7 +305,7 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
|
||||
const mapped: Record<string, unknown> = {
|
||||
type: 'function',
|
||||
name,
|
||||
name: getShortToolName(name, toolNameMap),
|
||||
};
|
||||
const description = asTrimmedString(fn.description);
|
||||
if (description) mapped.description = description;
|
||||
@@ -272,7 +315,10 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
}
|
||||
|
||||
if (type === 'function' && asTrimmedString(item.name)) {
|
||||
return item;
|
||||
return {
|
||||
...item,
|
||||
name: getShortToolName(asTrimmedString(item.name), toolNameMap),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'image_generation') {
|
||||
@@ -290,7 +336,10 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
||||
function convertOpenAiToolChoiceToResponses(
|
||||
rawToolChoice: unknown,
|
||||
toolNameMap: Record<string, string>,
|
||||
): unknown {
|
||||
if (rawToolChoice === undefined) return undefined;
|
||||
if (typeof rawToolChoice === 'string') return rawToolChoice;
|
||||
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||
@@ -299,7 +348,7 @@ function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
|
||||
if (type === 'function' && isRecord(rawToolChoice.function)) {
|
||||
const name = asTrimmedString(rawToolChoice.function.name);
|
||||
if (!name) return 'required';
|
||||
return { type: 'function', name };
|
||||
return { type: 'function', name: getShortToolName(name, toolNameMap) };
|
||||
}
|
||||
|
||||
return rawToolChoice;
|
||||
@@ -330,38 +379,12 @@ export function normalizeResponsesMessageContent(role: string, content: unknown)
|
||||
return [];
|
||||
}
|
||||
|
||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
||||
'model',
|
||||
'input',
|
||||
'instructions',
|
||||
'max_output_tokens',
|
||||
const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'parallel_tool_calls',
|
||||
'metadata',
|
||||
'reasoning',
|
||||
'store',
|
||||
'safety_identifier',
|
||||
'stream',
|
||||
'stream_options',
|
||||
'user',
|
||||
'max_tool_calls',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'background',
|
||||
'previous_response_id',
|
||||
'text',
|
||||
'audio',
|
||||
'include',
|
||||
'messages',
|
||||
'prompt',
|
||||
'response_format',
|
||||
'service_tier',
|
||||
'top_logprobs',
|
||||
'stop',
|
||||
'n',
|
||||
'verbosity',
|
||||
]);
|
||||
|
||||
export function sanitizeResponsesBodyForProxy(
|
||||
@@ -397,11 +420,9 @@ export function sanitizeResponsesBodyForProxy(
|
||||
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
|
||||
if (key === 'max_completion_tokens') continue;
|
||||
sanitized[key] = value;
|
||||
const sanitized: Record<string, unknown> = { ...normalized };
|
||||
for (const key of RESPONSES_COMPATIBILITY_FILTER_FIELDS) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
|
||||
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
|
||||
@@ -427,6 +448,7 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
const toolNameMap = buildShortToolNameMap(collectOpenAiToolNames(openaiBody));
|
||||
|
||||
for (const item of rawMessages) {
|
||||
if (!isRecord(item)) continue;
|
||||
@@ -466,7 +488,7 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
inputItems.push({
|
||||
type: 'function_call',
|
||||
call_id: callId,
|
||||
name,
|
||||
name: getShortToolName(name, toolNameMap),
|
||||
arguments: argumentsValue,
|
||||
});
|
||||
}
|
||||
@@ -520,7 +542,7 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
||||
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
|
||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
|
||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
||||
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
||||
@@ -546,7 +568,7 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
body.text = textConfig;
|
||||
}
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const TOOL_NAME_LIMIT = 64;
|
||||
const MCP_PREFIX = 'mcp__';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function shortenToolNameIfNeeded(name: string): string {
|
||||
const trimmed = asTrimmedString(name);
|
||||
if (trimmed.length <= TOOL_NAME_LIMIT) return trimmed;
|
||||
if (trimmed.startsWith(MCP_PREFIX)) {
|
||||
const lastSeparator = trimmed.lastIndexOf('__');
|
||||
if (lastSeparator > 0) {
|
||||
const candidate = `${MCP_PREFIX}${trimmed.slice(lastSeparator + 2)}`;
|
||||
return candidate.length > TOOL_NAME_LIMIT ? candidate.slice(0, TOOL_NAME_LIMIT) : candidate;
|
||||
}
|
||||
}
|
||||
return trimmed.slice(0, TOOL_NAME_LIMIT);
|
||||
}
|
||||
|
||||
export function buildShortToolNameMap(names: string[]): Record<string, string> {
|
||||
const uniqueNames = Array.from(new Set(
|
||||
names
|
||||
.map((name) => asTrimmedString(name))
|
||||
.filter((name) => name.length > 0),
|
||||
));
|
||||
const used = new Set<string>();
|
||||
const mapping: Record<string, string> = {};
|
||||
|
||||
for (const name of uniqueNames) {
|
||||
const base = shortenToolNameIfNeeded(name);
|
||||
let candidate = base;
|
||||
let suffixIndex = 1;
|
||||
while (used.has(candidate)) {
|
||||
const suffix = `_${suffixIndex}`;
|
||||
const allowedLength = Math.max(0, TOOL_NAME_LIMIT - suffix.length);
|
||||
candidate = `${base.slice(0, allowedLength)}${suffix}`;
|
||||
suffixIndex += 1;
|
||||
}
|
||||
used.add(candidate);
|
||||
mapping[name] = candidate;
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function getShortToolName(name: string, mapping: Record<string, string>): string {
|
||||
const trimmed = asTrimmedString(name);
|
||||
return mapping[trimmed] || shortenToolNameIfNeeded(trimmed);
|
||||
}
|
||||
Reference in New Issue
Block a user