complete codex cliproxyapi parity

This commit is contained in:
cita
2026-03-20 11:23:29 +08:00
parent 5403a946c6
commit adc4fb1be7
28 changed files with 2969 additions and 204 deletions
@@ -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);
}