1
0
forked from boos/BoosAPI

fix: preserve protocol parity across compatibility hops (#259)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* fix: preserve oe tool_result structure

* fix: preserve protocol parity across compatibility hops

* fix: satisfy server build and typecheck

* fix: preserve codex and mcp fallback semantics

* fix: address ci and review follow-ups

---------

Co-authored-by: bnvnvnv <bnvnvnv@gmail.com>
This commit is contained in:
Cita
2026-03-23 20:25:20 +08:00
committed by GitHub
parent b3869c4f55
commit 4a3b4a8bb6
25 changed files with 985 additions and 136 deletions
@@ -30,22 +30,11 @@ export const codexProviderProfile: ProviderProfile = {
openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta') openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta')
|| (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null), || (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null),
}); });
const codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id');
const shouldInjectDerivedPromptCacheKey = !!codexSessionId
&& !asTrimmedString(input.body.prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const body = shouldInjectDerivedPromptCacheKey
? {
...input.body,
prompt_cache_key: codexSessionId,
}
: input.body;
return { return {
path: '/responses', path: '/responses',
headers, headers,
body, body: input.body,
runtime: { runtime: {
executor: 'codex', executor: 'codex',
modelName: input.modelName, modelName: input.modelName,
@@ -39,7 +39,11 @@ describe('resolveProviderProfile', () => {
modelName: 'gpt-5.2-codex', modelName: 'gpt-5.2-codex',
stream: true, stream: true,
}); });
expect(result.body.prompt_cache_key).toBe(result.headers.Session_id); expect(result.body).toEqual({
model: 'gpt-5.2-codex',
stream: true,
store: false,
});
}); });
it('builds claude provider requests without rebuilding protocol bodies', () => { it('builds claude provider requests without rebuilding protocol bodies', () => {
@@ -177,10 +177,10 @@ describe('chat proxy codex oauth compatibility', () => {
const forwardedBody = JSON.parse(options.body); const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true); expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe(''); expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false); expect(forwardedBody.store).toBeUndefined();
expect(forwardedBody.parallel_tool_calls).toBe(true); expect(forwardedBody.parallel_tool_calls).toBeUndefined();
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']); expect(forwardedBody.include).toBeUndefined();
expect(forwardedBody.max_output_tokens).toBeUndefined(); expect(forwardedBody.max_output_tokens).toBe(256);
expect(forwardedBody.max_completion_tokens).toBeUndefined(); expect(forwardedBody.max_completion_tokens).toBeUndefined();
const body = response.json(); const body = response.json();
@@ -1921,6 +1921,7 @@ describe('chat proxy stream behavior', () => {
expect(forwarded.tool_choice?.function?.name).toBe('Glob'); expect(forwarded.tool_choice?.function?.name).toBe('Glob');
}); });
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => { it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({ fetchModelPricingCatalogMock.mockResolvedValue({
models: [ models: [
@@ -298,6 +298,22 @@ describe('executeEndpointFlow', () => {
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses');
}); });
it('normalizes proxyUrl with versioned base paths instead of duplicating path segments', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
await executeEndpointFlow({
siteUrl: 'https://example.com',
proxyUrl: 'https://proxy.internal/api/v1?mode=relay#frag',
endpointCandidates: ['responses'],
buildRequest: () => requestFor('/v1/responses'),
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/api/v1/responses?mode=relay#frag');
});
it('returns normalized final error when all endpoints fail', async () => { it('returns normalized final error when all endpoints fail', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' }, error: { message: 'upstream_error', type: 'upstream_error' },
+2 -8
View File
@@ -76,12 +76,6 @@ type ExecuteEndpointFlowInput = {
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>; onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
}; };
function buildAbsoluteUrl(base: string, path: string): string {
const normalizedBase = base.replace(/\/+$/, '');
const normalizedPath = path.replace(/^\/+/, '');
return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase;
}
async function runEndpointFlowHook<T>( async function runEndpointFlowHook<T>(
hook: ((ctx: T) => void | Promise<void>) | undefined, hook: ((ctx: T) => void | Promise<void>) | undefined,
ctx: T, ctx: T,
@@ -113,7 +107,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
const request = input.buildRequest(endpoint, endpointIndex); const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl const targetUrl = input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, request.path) ? buildUpstreamUrl(input.proxyUrl, request.path)
: defaultTarget; : defaultTarget;
let response = input.dispatchRequest let response = input.dispatchRequest
@@ -155,7 +149,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
const recoveredRequest = recovered.request ?? baseContext.request; const recoveredRequest = recovered.request ?? baseContext.request;
const recoveredTargetUrl = recovered.targetUrl ?? ( const recoveredTargetUrl = recovered.targetUrl ?? (
input.proxyUrl input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, recovered.upstreamPath) ? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath) : buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
); );
await runEndpointFlowHook(input.onAttemptSuccess, { await runEndpointFlowHook(input.onAttemptSuccess, {
+26 -11
View File
@@ -143,11 +143,27 @@ describe('/v1/images/edits route', () => {
expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits'); expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits');
}); });
it('returns an upstream error instead of fabricating an empty success when image generation JSON is malformed', async () => { it('retries the next channel when image generation JSON is malformed', async () => {
fetchMock.mockResolvedValue(new Response('not-json', { selectNextChannelMock.mockReturnValueOnce({
status: 200, channel: { id: 12, routeId: 23 },
headers: { 'content-type': 'application/json' }, site: { id: 45, name: 'fallback-site', url: 'https://fallback.example.com', platform: 'openai' },
})); account: { id: 34, username: 'fallback-user' },
tokenName: 'fallback',
tokenValue: 'sk-fallback',
actualModel: 'fallback-gpt-image',
});
fetchMock
.mockResolvedValueOnce(new Response('not-json', {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
created: 2,
data: [{ b64_json: 'ZmFsbGJhY2s=' }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -161,14 +177,13 @@ describe('/v1/images/edits route', () => {
}, },
}); });
expect(response.statusCode).toBe(502); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ expect(response.json()).toEqual({
error: { created: 2,
message: 'not-json', data: [{ b64_json: 'ZmFsbGJhY2s=' }],
type: 'upstream_error',
},
}); });
expect(selectNextChannelMock).not.toHaveBeenCalled(); expect(selectNextChannelMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
}); });
it('keeps returning a successful image edit response when post-success accounting fails', async () => { it('keeps returning a successful image edit response when post-success accounting fails', async () => {
+8
View File
@@ -132,6 +132,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
downstreamPath, downstreamPath,
clientContext, clientContext,
); );
if (canRetryProxyChannel(retryCount)) {
retryCount++;
continue;
}
await reportProxyAllFailed({ await reportProxyAllFailed({
model: requestedModel, model: requestedModel,
reason: data.message, reason: data.message,
@@ -324,6 +328,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
downstreamPath, downstreamPath,
clientContext, clientContext,
); );
if (canRetryProxyChannel(retryCount)) {
retryCount++;
continue;
}
await reportProxyAllFailed({ await reportProxyAllFailed({
model: requestedModel, model: requestedModel,
reason: data.message, reason: data.message,
@@ -525,7 +525,8 @@ describe('responses proxy codex oauth refresh', () => {
const forwardedBody = JSON.parse(options.body); const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true); expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe(''); expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false); expect(forwardedBody.store).toBeUndefined();
expect(forwardedBody.max_output_tokens).toBeUndefined();
expect(response.json()).toMatchObject({ expect(response.json()).toMatchObject({
id: 'resp_codex_stream', id: 'resp_codex_stream',
@@ -567,11 +568,13 @@ describe('responses proxy codex oauth refresh', () => {
const secondBody = JSON.parse(secondOptions.body); const secondBody = JSON.parse(secondOptions.body);
expect(firstBody.instructions).toBe(''); expect(firstBody.instructions).toBe('');
expect(firstBody.store).toBe(false); expect(firstBody.store).toBeUndefined();
expect(firstBody.stream).toBe(true); expect(firstBody.stream).toBe(true);
expect(firstBody.max_output_tokens).toBeUndefined();
expect(secondBody.instructions).toBe(''); expect(secondBody.instructions).toBe('');
expect(secondBody.store).toBe(false); expect(secondBody.store).toBeUndefined();
expect(secondBody.stream).toBe(true); expect(secondBody.stream).toBe(true);
expect(secondBody.max_output_tokens).toBeUndefined();
}); });
it('does not record success when a streaming responses request ends with response.failed', async () => { it('does not record success when a streaming responses request ends with response.failed', async () => {
@@ -626,16 +626,16 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers.Accept).toBe('text/event-stream'); expect(request.headers.Accept).toBe('text/event-stream');
expect(request.headers.Connection).toBe('Keep-Alive'); expect(request.headers.Connection).toBe('Keep-Alive');
expect(request.body.instructions).toBe(''); expect(request.body.instructions).toBe('');
expect(request.body.prompt_cache_key).toBe(request.headers.Session_id); expect(request.body.prompt_cache_key).toBeUndefined();
expect(request.body.stream).toBe(true); expect(request.body.stream).toBe(false);
expect(request.body.store).toBe(false); expect(request.body.store).toBeUndefined();
expect(request.body.parallel_tool_calls).toBe(true); expect(request.body.parallel_tool_calls).toBeUndefined();
expect(request.body.include).toEqual(['reasoning.encrypted_content']); expect(request.body.include).toBeUndefined();
expect(request.body.max_output_tokens).toBeUndefined(); expect(request.body.max_output_tokens).toBe(4096);
expect(request.body.temperature).toBeUndefined(); expect(request.body.temperature).toBe(0.2);
expect(request.body.top_p).toBeUndefined(); expect(request.body.top_p).toBe(0.9);
expect(request.body.user).toBeUndefined(); expect(request.body.user).toBe('drop-me');
expect(request.body.service_tier).toBeUndefined(); expect(request.body.service_tier).toBe('auto');
}); });
it('reuses a stable codex session id when the same downstream continuity key is provided', () => { it('reuses a stable codex session id when the same downstream continuity key is provided', () => {
@@ -729,6 +729,48 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.body.prompt_cache_key).toBe('codex-cache-123'); expect(request.body.prompt_cache_key).toBe('codex-cache-123');
}); });
it('preserves native codex responses continuity and request fields without compatibility rewrites', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {},
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
input: 'hello codex',
stream: false,
store: true,
parallel_tool_calls: false,
include: ['reasoning.encrypted_content', 'mcp_approval_request.details'],
previous_response_id: 'resp_prev_123',
temperature: 0.3,
top_p: 0.8,
max_output_tokens: 512,
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
codexSessionCacheKey: 'gpt-5.4:user-456',
} as any);
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
expect(request.headers.Conversation_id).toBe(request.headers.Session_id);
expect(request.body.prompt_cache_key).toBeUndefined();
expect(request.body.instructions).toBe('');
expect(request.body.stream).toBe(false);
expect(request.body.store).toBe(true);
expect(request.body.parallel_tool_calls).toBe(false);
expect(request.body.include).toEqual(['reasoning.encrypted_content', 'mcp_approval_request.details']);
expect(request.body.previous_response_id).toBe('resp_prev_123');
expect(request.body.temperature).toBe(0.3);
expect(request.body.top_p).toBe(0.8);
expect(request.body.max_output_tokens).toBe(512);
});
it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => { it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => {
(config as any).codexHeaderDefaults = { (config as any).codexHeaderDefaults = {
userAgent: 'codex-config-ua/1.0', userAgent: 'codex-config-ua/1.0',
+1 -35
View File
@@ -413,12 +413,7 @@ function ensureCodexResponsesStoreFalse(
body: Record<string, unknown>, body: Record<string, unknown>,
sitePlatform: string, sitePlatform: string,
): Record<string, unknown> { ): Record<string, unknown> {
if (sitePlatform !== 'codex') return body; return body;
if (body.store === false) return body;
return {
...body,
store: false,
};
} }
function convertCodexSystemRoleToDeveloper(input: unknown): unknown { function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
@@ -437,18 +432,11 @@ function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
function applyCodexResponsesCompatibility( function applyCodexResponsesCompatibility(
body: Record<string, unknown>, body: Record<string, unknown>,
sitePlatform: string, sitePlatform: string,
options?: {
preservePreviousResponseId?: boolean;
},
): Record<string, unknown> { ): Record<string, unknown> {
if (sitePlatform !== 'codex') return body; if (sitePlatform !== 'codex') return body;
const next: Record<string, unknown> = { const next: Record<string, unknown> = {
...body, ...body,
stream: true,
store: false,
parallel_tool_calls: true,
include: ['reasoning.encrypted_content'],
input: convertCodexSystemRoleToDeveloper(body.input), input: convertCodexSystemRoleToDeveloper(body.input),
}; };
@@ -456,27 +444,6 @@ function applyCodexResponsesCompatibility(
next.instructions = ''; next.instructions = '';
} }
for (const key of [
'max_output_tokens',
'max_completion_tokens',
'temperature',
'top_p',
'truncation',
'user',
'context_management',
'prompt_cache_retention',
'safety_identifier',
]) {
delete next[key];
}
if (!options?.preservePreviousResponseId) {
delete next.previous_response_id;
}
if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') {
delete next.service_tier;
}
return next; return next;
} }
@@ -1284,7 +1251,6 @@ export function buildUpstreamEndpointRequest(input: {
applyCodexResponsesCompatibility( applyCodexResponsesCompatibility(
sanitizedResponsesBody, sanitizedResponsesBody,
sitePlatform, sitePlatform,
{ preservePreviousResponseId: preserveWebsocketIncrementalMode },
), ),
sitePlatform, sitePlatform,
), ),
@@ -562,6 +562,53 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
]); ]);
}); });
it('preserves image blocks inside tool_result content arrays', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
messages: [
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: [
{ type: 'text', text: 'found 1 image' },
{
type: 'image_url',
image_url: 'https://example.com/cat.png',
},
],
},
],
},
],
});
expect(result.messages).toEqual([
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool_1',
content: [
{ type: 'text', text: 'found 1 image' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/cat.png',
},
},
],
cache_control: { type: 'ephemeral' },
},
],
},
]);
});
it('adds a second message cache anchor around the 20-block window for long prompts', () => { it('adds a second message cache anchor around the 20-block window for long prompts', () => {
const longContent = Array.from({ length: 25 }, (_, index) => ({ const longContent = Array.from({ length: 25 }, (_, index) => ({
type: 'text', type: 'text',
@@ -603,6 +650,57 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
expect(body.output_config).toEqual({ effort: 'high' }); expect(body.output_config).toEqual({ effort: 'high' });
}); });
it('copies metadata.user_id into the anthropic payload', () => {
const metadata = { user_id: 'anthropic-user-1', trace_id: 'trace-xyz' };
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
metadata,
messages: [{ role: 'user', content: 'hello' }],
},
'claude-opus-4-6',
true,
);
expect(body.metadata).toEqual(metadata);
});
it('marks tool_choice.disable_parallel_tool_use when parallel_tool_calls is false', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
parallel_tool_calls: false,
messages: [{ role: 'user', content: 'hello' }],
},
'claude-opus-4-6',
true,
);
expect(body.tool_choice).toMatchObject({
type: 'auto',
disable_parallel_tool_use: true,
});
});
it('augments explicit tool_choice with disable_parallel_tool_use when parallel_tool_calls is false', () => {
const body = convertOpenAiBodyToAnthropicMessagesBody(
{
model: 'gpt-5',
parallel_tool_calls: false,
tool_choice: { type: 'tool', name: 'search' },
messages: [{ role: 'user', content: 'hello' }],
},
'claude-opus-4-6',
true,
);
expect(body.tool_choice).toMatchObject({
type: 'tool',
name: 'search',
disable_parallel_tool_use: true,
});
});
it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => { it('keeps inbound claude bodies thin instead of rebuilding cache anchors', () => {
const result = anthropicMessagesInbound.parse( const result = anthropicMessagesInbound.parse(
{ {
@@ -29,6 +29,14 @@ function safeJsonStringify(value: unknown): string {
} }
} }
function cloneJsonValue<T>(value: T): T | undefined {
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return undefined;
}
}
function parseJsonString(raw: string): unknown { function parseJsonString(raw: string): unknown {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return {}; if (!trimmed) return {};
@@ -187,6 +195,16 @@ function normalizeTextCandidate(value: unknown): string {
return asTrimmedString(value.text ?? value.content ?? value.output_text); return asTrimmedString(value.text ?? value.content ?? value.output_text);
} }
function ensureAnthropicToolChoiceRecord(value: unknown): Record<string, unknown> | undefined {
if (isRecord(value)) return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
return { type: normalized };
}
return undefined;
}
function sanitizeToolReferenceBlock(item: Record<string, unknown>): Record<string, unknown> | null { function sanitizeToolReferenceBlock(item: Record<string, unknown>): Record<string, unknown> | null {
const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name); const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name);
if (!toolName) return null; if (!toolName) return null;
@@ -215,6 +233,13 @@ function normalizeToolMessageContent(raw: unknown): string | Array<Record<string
if (type === 'text' || type === 'input_text' || type === 'output_text') { if (type === 'text' || type === 'input_text' || type === 'output_text') {
return toAnthropicTextBlock(normalizeTextCandidate(item)); return toAnthropicTextBlock(normalizeTextCandidate(item));
} }
if (type === 'image_url' || type === 'input_image') {
return toAnthropicImageBlock(item);
}
if (type === 'file' || type === 'input_file') {
const fileBlock = normalizeInputFileBlock(item);
return fileBlock ? toAnthropicDocumentBlock(fileBlock) : null;
}
return null; return null;
}) })
.filter((item): item is Record<string, unknown> => item !== null); .filter((item): item is Record<string, unknown> => item !== null);
@@ -232,6 +257,17 @@ function normalizeToolMessageContent(raw: unknown): string | Array<Record<string
} }
if (isRecord(raw)) { if (isRecord(raw)) {
const type = asTrimmedString(raw.type).toLowerCase();
if (type === 'image_url' || type === 'input_image') {
const imageBlock = toAnthropicImageBlock(raw);
return imageBlock ? [imageBlock] : '';
}
if (type === 'file' || type === 'input_file') {
const fileBlock = normalizeInputFileBlock(raw);
if (!fileBlock) return '';
const documentBlock = toAnthropicDocumentBlock(fileBlock);
return documentBlock ? [documentBlock] : '';
}
const text = normalizeTextCandidate(raw); const text = normalizeTextCandidate(raw);
return text || safeJsonStringify(raw); return text || safeJsonStringify(raw);
} }
@@ -940,6 +976,14 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096, max_tokens: toFiniteNumber(openaiBody.max_tokens) ?? 4096,
}; };
const openAiMetadata = openaiBody.metadata;
if (isRecord(openAiMetadata)) {
const clonedMetadata = cloneJsonValue(openAiMetadata);
if (isRecord(clonedMetadata) && 'user_id' in clonedMetadata) {
body.metadata = clonedMetadata;
}
}
if (systemContents.length > 0) { if (systemContents.length > 0) {
body.system = systemContents.join('\n\n'); body.system = systemContents.join('\n\n');
} }
@@ -956,8 +1000,17 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools); if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToAnthropic(openaiBody.tools);
const anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice); const parallelToolCalls = openaiBody.parallel_tool_calls;
if (anthropicToolChoice !== undefined) body.tool_choice = anthropicToolChoice; let anthropicToolChoice = convertOpenAiToolChoiceToAnthropic(openaiBody.tool_choice);
if (parallelToolCalls === false) {
const choiceRecord = ensureAnthropicToolChoiceRecord(anthropicToolChoice) ?? { type: 'auto' };
choiceRecord.disable_parallel_tool_use = true;
anthropicToolChoice = choiceRecord;
}
if (anthropicToolChoice !== undefined) {
body.tool_choice = anthropicToolChoice;
}
const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody); const reasoningSettings = resolveOpenAiReasoningSettings(openaiBody);
if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking; if (reasoningSettings.thinking) body.thinking = reasoningSettings.thinking;
@@ -214,4 +214,162 @@ describe('canonical request helpers', () => {
include: ['reasoning.encrypted_content', 'message.input_image.image_url'], include: ['reasoning.encrypted_content', 'message.input_image.image_url'],
}); });
}); });
it('preserves extra fields on tool-shaped raw tool_choice objects', () => {
const request = canonicalRequestFromOpenAiBody({
body: {
model: 'gpt-5',
tool_choice: {
type: 'tool',
name: 'browser',
mode: 'required',
disable_parallel_tool_use: true,
},
messages: [{ role: 'user', content: 'hello' }],
},
surface: 'openai-responses',
});
const body = canonicalRequestToOpenAiChatBody(request);
expect(body.tool_choice).toEqual({
type: 'tool',
name: 'browser',
mode: 'required',
disable_parallel_tool_use: true,
});
});
it('preserves structured tool outputs and top-level attachments through canonical round-trips', () => {
const request = createCanonicalRequestEnvelope({
requestedModel: 'gpt-5',
surface: 'openai-chat',
attachments: [{
kind: 'file',
fileId: 'file-top-level',
}],
messages: [{
role: 'tool',
parts: [{
type: 'tool_result',
toolCallId: 'call_1',
resultContent: [
{ type: 'text', text: 'tool result' },
{ type: 'image_url', image_url: { url: 'https://example.com/tool.png' } },
],
} as any],
}],
});
const body = canonicalRequestToOpenAiChatBody(request);
expect(body.attachments).toEqual([{
kind: 'file',
fileId: 'file-top-level',
}]);
expect(body.messages).toEqual([{
role: 'tool',
tool_call_id: 'call_1',
content: [
{ type: 'text', text: 'tool result' },
{ type: 'image_url', image_url: { url: 'https://example.com/tool.png' } },
],
}]);
});
it('preserves richer Responses tools, raw tool_choice, assistant phase, and reasoning signatures through canonical round-trips', () => {
const request = canonicalRequestFromOpenAiBody({
body: {
model: 'gpt-5',
parallel_tool_calls: false,
tools: [
{
type: 'custom',
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
},
{
type: 'image_generation',
background: 'transparent',
},
],
tool_choice: {
type: 'allowed_tools',
mode: 'auto',
tools: [{ type: 'custom', name: 'browser' }],
},
messages: [
{
role: 'assistant',
phase: 'analysis',
reasoning_signature: 'sig_123',
content: 'thinking',
},
{
role: 'user',
content: 'hello',
},
],
},
surface: 'openai-responses',
});
const body = canonicalRequestToOpenAiChatBody(request);
expect(body.parallel_tool_calls).toBe(false);
expect(body.tools).toEqual([
{
type: 'custom',
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
},
{
type: 'image_generation',
background: 'transparent',
},
]);
expect(body.tool_choice).toEqual({
type: 'allowed_tools',
mode: 'auto',
tools: [{ type: 'custom', name: 'browser' }],
});
expect(body.messages).toMatchObject([
{
role: 'assistant',
phase: 'analysis',
reasoning_signature: 'sig_123',
content: 'thinking',
},
{
role: 'user',
content: 'hello',
},
]);
});
it('writes raw canonical tool types back into OpenAI-compatible bodies when the raw payload omits the discriminator', () => {
const request = createCanonicalRequestEnvelope({
requestedModel: 'gpt-5',
surface: 'openai-responses',
tools: [{
type: 'custom',
raw: {
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
},
}],
});
const body = canonicalRequestToOpenAiChatBody(request);
expect(body.tools).toEqual([{
type: 'custom',
name: 'browser',
description: 'browse the web',
format: { type: 'text' },
}]);
});
}); });
+80 -27
View File
@@ -104,6 +104,9 @@ export function createCanonicalRequestEnvelope(
: {}), : {}),
...(input.metadata ? { metadata: input.metadata } : {}), ...(input.metadata ? { metadata: input.metadata } : {}),
...(input.passthrough ? { passthrough: input.passthrough } : {}), ...(input.passthrough ? { passthrough: input.passthrough } : {}),
...(Array.isArray(input.attachments) && input.attachments.length > 0
? { attachments: cloneJsonValue(input.attachments) as CanonicalAttachment[] }
: {}),
}; };
} }
@@ -190,7 +193,7 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine
const normalized = rawToolChoice.trim().toLowerCase(); const normalized = rawToolChoice.trim().toLowerCase();
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized; if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
if (normalized === 'any') return 'required'; if (normalized === 'any') return 'required';
return undefined; return rawToolChoice.trim() ? { type: 'raw', value: rawToolChoice } : undefined;
} }
if (!isRecord(rawToolChoice)) return undefined; if (!isRecord(rawToolChoice)) return undefined;
@@ -204,20 +207,28 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine
); );
return name ? { type: 'tool', name } : undefined; return name ? { type: 'tool', name } : undefined;
} }
if (type !== 'tool') return undefined; if (type && type !== 'tool') {
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
}
const name = asTrimmedString( const name = asTrimmedString(
rawToolChoice.name rawToolChoice.name
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
); );
return name ? { type: 'tool', name } : undefined; const toolChoiceKeys = Object.keys(rawToolChoice);
const hasExtraToolFields = toolChoiceKeys.some((key) => key !== 'type' && key !== 'name' && key !== 'tool');
if (hasExtraToolFields) {
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
}
if (name) return { type: 'tool', name };
return { type: 'raw', value: cloneJsonValue(rawToolChoice) as Record<string, unknown> };
} }
function parseTools(rawTools: unknown): CanonicalTool[] | undefined { function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
if (!Array.isArray(rawTools)) return undefined; if (!Array.isArray(rawTools)) return undefined;
const tools = rawTools const tools: CanonicalTool[] = rawTools
.flatMap((item) => { .flatMap((item): CanonicalTool[] => {
if (!isRecord(item)) return []; if (!isRecord(item)) return [];
const itemType = asTrimmedString(item.type).toLowerCase(); const itemType = asTrimmedString(item.type).toLowerCase();
@@ -264,6 +275,13 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
}); });
} }
if (itemType) {
return [{
type: itemType,
raw: cloneJsonValue(item) as Record<string, unknown>,
}];
}
return []; return [];
}); });
@@ -277,6 +295,9 @@ export function canonicalRequestFromOpenAiBody(
const metadata = isRecord(input.metadata) const metadata = isRecord(input.metadata)
? input.metadata ? input.metadata
: (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined);
const attachments = Array.isArray(body.attachments)
? cloneJsonValue(body.attachments) as CanonicalAttachment[]
: undefined;
const messages: CanonicalMessage[] = []; const messages: CanonicalMessage[] = [];
const rawMessages = Array.isArray(body.messages) ? body.messages : []; const rawMessages = Array.isArray(body.messages) ? body.messages : [];
@@ -286,15 +307,21 @@ export function canonicalRequestFromOpenAiBody(
if (role === 'tool') { if (role === 'tool') {
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id); const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
const resultText = typeof rawMessage.content === 'string' const rawContent = rawMessage.content;
? rawMessage.content const resultText = typeof rawContent === 'string'
: safeJsonStringify(rawMessage.content ?? ''); ? rawContent
: (!Array.isArray(rawContent) && !isRecord(rawContent) ? safeJsonStringify(rawContent ?? '') : '');
messages.push({ messages.push({
role: 'tool', role: 'tool',
parts: [{ parts: [{
type: 'tool_result', type: 'tool_result',
toolCallId: toolCallId || 'tool', toolCallId: toolCallId || 'tool',
...(resultText ? { resultText } : {}), ...(resultText ? { resultText } : {}),
...(Array.isArray(rawContent)
? { resultContent: cloneJsonValue(rawContent) as Array<string | Record<string, unknown>> }
: (isRecord(rawContent)
? { resultContent: [cloneJsonValue(rawContent) as Record<string, unknown>] }
: {})),
}], }],
}); });
continue; continue;
@@ -322,6 +349,10 @@ export function canonicalRequestFromOpenAiBody(
messages.push({ messages.push({
role, role,
parts, parts,
...(asTrimmedString(rawMessage.phase) ? { phase: asTrimmedString(rawMessage.phase) } : {}),
...(asTrimmedString(rawMessage.reasoning_signature)
? { reasoningSignature: asTrimmedString(rawMessage.reasoning_signature) }
: {}),
}); });
} }
@@ -343,6 +374,14 @@ export function canonicalRequestFromOpenAiBody(
: {}), : {}),
}; };
const passthrough = {
...(input.passthrough ?? {}),
...(typeof body.parallel_tool_calls === 'boolean'
? { parallel_tool_calls: body.parallel_tool_calls }
: {}),
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
};
return createCanonicalRequestEnvelope({ return createCanonicalRequestEnvelope({
operation: input.operation ?? 'generate', operation: input.operation ?? 'generate',
surface: input.surface, surface: input.surface,
@@ -355,14 +394,8 @@ export function canonicalRequestFromOpenAiBody(
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
...(Object.keys(continuation).length > 0 ? { continuation } : {}), ...(Object.keys(continuation).length > 0 ? { continuation } : {}),
...(metadata ? { metadata } : {}), ...(metadata ? { metadata } : {}),
...((reasoningResult.metadata || input.passthrough) ...(attachments ? { attachments } : {}),
? { ...(Object.keys(passthrough).length > 0 ? { passthrough } : {}),
passthrough: {
...(input.passthrough ?? {}),
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
},
}
: {}),
}); });
} }
@@ -418,7 +451,10 @@ function canonicalPartsToOpenAiContent(
continue; continue;
} }
if (part.type === 'tool_result' && role !== 'tool') { if (part.type === 'tool_result' && role !== 'tool') {
const text = part.resultText ?? safeJsonStringify(part.resultJson ?? ''); const text = part.resultText
?? (typeof part.resultContent === 'string'
? part.resultContent
: safeJsonStringify(part.resultJson ?? part.resultContent ?? ''));
if (text) { if (text) {
visibleText.push(text); visibleText.push(text);
} }
@@ -451,6 +487,7 @@ function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined
if (!toolChoice) return undefined; if (!toolChoice) return undefined;
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice; if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
if (toolChoice === 'required') return 'required'; if (toolChoice === 'required') return 'required';
if (toolChoice.type === 'raw') return cloneJsonValue(toolChoice.value);
return { return {
type: 'function', type: 'function',
function: { function: {
@@ -471,7 +508,9 @@ export function canonicalRequestToOpenAiChatBody(
messages.push({ messages.push({
role: 'tool', role: 'tool',
tool_call_id: part.toolCallId, tool_call_id: part.toolCallId,
content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''), content: part.resultContent
?? part.resultText
?? safeJsonStringify(part.resultJson ?? ''),
}); });
} }
continue; continue;
@@ -485,6 +524,8 @@ export function canonicalRequestToOpenAiChatBody(
if (message.role === 'assistant' && converted.reasoning) { if (message.role === 'assistant' && converted.reasoning) {
nextMessage.reasoning_content = converted.reasoning; nextMessage.reasoning_content = converted.reasoning;
} }
if (message.phase) nextMessage.phase = message.phase;
if (message.reasoningSignature) nextMessage.reasoning_signature = message.reasoningSignature;
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) { if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
nextMessage.tool_calls = converted.toolCalls; nextMessage.tool_calls = converted.toolCalls;
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) { if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
@@ -517,18 +558,30 @@ export function canonicalRequestToOpenAiChatBody(
].filter((item, index, all) => all.indexOf(item) === index); ].filter((item, index, all) => all.indexOf(item) === index);
if (mergedInclude.length > 0) body.include = mergedInclude; if (mergedInclude.length > 0) body.include = mergedInclude;
if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata); if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata);
if (Array.isArray(request.attachments) && request.attachments.length > 0) {
body.attachments = cloneJsonValue(request.attachments);
}
if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
if (Array.isArray(request.tools) && request.tools.length > 0) { if (Array.isArray(request.tools) && request.tools.length > 0) {
body.tools = request.tools.map((tool) => ({ body.tools = request.tools.map((tool) => {
type: 'function', if ('raw' in tool) {
function: { const raw = cloneJsonValue(tool.raw) as Record<string, unknown>;
name: tool.name, if (typeof raw.type !== 'string' || raw.type.trim().length === 0) {
...(tool.description ? { description: tool.description } : {}), raw.type = tool.type;
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), }
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), return raw;
}, }
})); return {
type: 'function',
function: {
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
},
};
});
} }
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice); const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
if (toolChoice !== undefined) body.tool_choice = toolChoice; if (toolChoice !== undefined) body.tool_choice = toolChoice;
+24 -1
View File
@@ -1,10 +1,19 @@
export type CanonicalTool = { export type CanonicalFunctionTool = {
name: string; name: string;
description?: string; description?: string;
strict?: boolean; strict?: boolean;
inputSchema?: Record<string, unknown> | null; inputSchema?: Record<string, unknown> | null;
}; };
export type CanonicalRawTool = {
type: string;
raw: Record<string, unknown>;
};
export type CanonicalTool =
| CanonicalFunctionTool
| CanonicalRawTool;
export type CanonicalToolChoice = export type CanonicalToolChoice =
| 'auto' | 'auto'
| 'none' | 'none'
@@ -12,4 +21,18 @@ export type CanonicalToolChoice =
| { | {
type: 'tool'; type: 'tool';
name: string; name: string;
}
| {
type: 'raw';
value: string | Record<string, unknown>;
}; };
export function isCanonicalFunctionTool(tool: CanonicalTool): tool is CanonicalFunctionTool {
return 'name' in tool;
}
export function isCanonicalNamedToolChoice(
toolChoice: CanonicalToolChoice | undefined,
): toolChoice is { type: 'tool'; name: string } {
return !!toolChoice && typeof toolChoice === 'object' && toolChoice.type === 'tool';
}
@@ -1,3 +1,4 @@
import type { CanonicalAttachment } from './attachments.js';
import type { CanonicalTool, CanonicalToolChoice } from './tools.js'; import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
export type CanonicalOperation = export type CanonicalOperation =
@@ -57,6 +58,7 @@ export type CanonicalToolResultPart = {
toolCallId: string; toolCallId: string;
resultText?: string; resultText?: string;
resultJson?: unknown; resultJson?: unknown;
resultContent?: string | Array<string | Record<string, unknown>>;
}; };
export type CanonicalContentPart = export type CanonicalContentPart =
@@ -69,6 +71,8 @@ export type CanonicalContentPart =
export type CanonicalMessage = { export type CanonicalMessage = {
role: CanonicalMessageRole; role: CanonicalMessageRole;
parts: CanonicalContentPart[]; parts: CanonicalContentPart[];
phase?: string;
reasoningSignature?: string;
}; };
export type CanonicalReasoningEffort = export type CanonicalReasoningEffort =
@@ -105,4 +109,5 @@ export type CanonicalRequestEnvelope = {
continuation?: CanonicalContinuation; continuation?: CanonicalContinuation;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
passthrough?: Record<string, unknown>; passthrough?: Record<string, unknown>;
attachments?: CanonicalAttachment[];
}; };
@@ -1,4 +1,8 @@
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js'; import {
canonicalRequestFromOpenAiBody,
isCanonicalFunctionTool,
isCanonicalNamedToolChoice,
} from '../../canonical/request.js';
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
import { import {
@@ -218,13 +222,16 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec
} }
if (Array.isArray(request.tools) && request.tools.length > 0) { if (Array.isArray(request.tools) && request.tools.length > 0) {
payload.tools = [{ const functionTools = request.tools.filter(isCanonicalFunctionTool);
functionDeclarations: request.tools.map((tool) => ({ if (functionTools.length > 0) {
name: tool.name, payload.tools = [{
...(tool.description ? { description: tool.description } : {}), functionDeclarations: functionTools.map((tool) => ({
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}), name: tool.name,
})), ...(tool.description ? { description: tool.description } : {}),
}]; ...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
})),
}];
}
} }
if (request.toolChoice) { if (request.toolChoice) {
@@ -234,7 +241,7 @@ function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Rec
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } }; payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
} else if (request.toolChoice === 'required') { } else if (request.toolChoice === 'required') {
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } }; payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
} else { } else if (isCanonicalNamedToolChoice(request.toolChoice)) {
payload.toolConfig = { payload.toolConfig = {
functionCallingConfig: { functionCallingConfig: {
mode: 'ANY', mode: 'ANY',
@@ -683,6 +683,86 @@ describe('convertOpenAiBodyToResponsesBody', () => {
]); ]);
}); });
it('preserves structured tool outputs and assistant phase when converting Responses bodies to OpenAI-compatible messages', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'function_call',
call_id: 'call_1',
name: 'lookup_weather',
arguments: '{"city":"Shanghai"}',
},
{
type: 'function_call_output',
call_id: 'call_1',
output: [
{
type: 'output_text',
text: 'tool result',
},
{
type: 'input_image',
image_url: 'https://example.com/tool.png',
},
],
},
{
type: 'message',
role: 'assistant',
phase: 'analysis',
content: [
{
type: 'output_text',
text: 'done',
},
],
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'assistant',
content: '',
tool_calls: [{
id: 'call_1',
type: 'function',
function: {
name: 'lookup_weather',
arguments: '{"city":"Shanghai"}',
},
}],
},
{
role: 'tool',
tool_call_id: 'call_1',
content: [
{
type: 'text',
text: 'tool result',
},
{
type: 'image_url',
image_url: 'https://example.com/tool.png',
},
],
},
{
role: 'assistant',
phase: 'analysis',
content: [{
type: 'text',
text: 'done',
}],
},
]);
});
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => { 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 sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
const firstName = `mcp__alpha_workspace__${sharedSuffix}`; const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
@@ -944,6 +1024,65 @@ describe('convertResponsesBodyToOpenAiBody', () => {
]); ]);
}); });
it('round-trips mcp item families through OpenAI-compatible fallback using compatibility tool calls', () => {
const source = {
model: 'gpt-5',
input: [
{
type: 'mcp_call',
id: 'mcp_call_1',
call_id: 'mcp_call_1',
name: 'read_file',
server_label: 'filesystem',
arguments: {
path: '/tmp/demo.txt',
},
},
{
type: 'mcp_approval_request',
id: 'mcp_approval_request_1',
approval_request_id: 'mcp_approval_request_1',
name: 'read_file',
server_label: 'filesystem',
arguments: {
path: '/tmp/demo.txt',
},
},
{
type: 'mcp_approval_response',
id: 'mcp_approval_response_1',
approval_request_id: 'mcp_approval_request_1',
approve: true,
},
],
};
const openAiBody = convertResponsesBodyToOpenAiBody(
source,
'gpt-5',
false,
);
expect(openAiBody.messages).toHaveLength(1);
expect(openAiBody.messages[0]).toMatchObject({
role: 'assistant',
content: '',
tool_calls: [
{ id: 'mcp_call_1', type: 'function' },
{ id: 'mcp_approval_request_1', type: 'function' },
{ id: 'mcp_approval_response_1', type: 'function' },
],
});
const roundTripped = convertOpenAiBodyToResponsesBody(
openAiBody,
'gpt-5',
false,
);
expect(roundTripped.input).toEqual(source.input);
});
it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => { it('preserves remaining request fields needed for OpenAI-compatible downstream fallback', () => {
const result = convertResponsesBodyToOpenAiBody( const result = convertResponsesBodyToOpenAiBody(
{ {
@@ -3,6 +3,11 @@ import {
normalizeResponsesMessageContentBlocks, normalizeResponsesMessageContentBlocks,
normalizeResponsesMessageItem, normalizeResponsesMessageItem,
} from './normalization.js'; } from './normalization.js';
import {
decodeResponsesMcpCompatToolCall,
isResponsesMcpItem,
toResponsesMcpCompatToolCall,
} from './mcpCompatibility.js';
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js'; import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js'; import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
@@ -241,9 +246,12 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
return ''; return '';
} }
function normalizeToolOutput(raw: unknown): string { function normalizeToolOutput(raw: unknown): string | Array<string | Record<string, unknown>> {
const text = extractTextContent(raw).trim(); const normalizedContent = toOpenAiMessageContent(raw);
if (text) return text; const hasNormalizedContent = typeof normalizedContent === 'string'
? normalizedContent.trim().length > 0
: Array.isArray(normalizedContent) && normalizedContent.length > 0;
if (hasNormalizedContent) return normalizedContent;
if (raw === undefined || raw === null) return ''; if (raw === undefined || raw === null) return '';
if (typeof raw === 'string') return raw; if (typeof raw === 'string') return raw;
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
@@ -498,6 +506,14 @@ export function convertOpenAiBodyToResponsesBody(
const toolCall = rawToolCalls[index]; const toolCall = rawToolCalls[index];
if (!isRecord(toolCall)) continue; if (!isRecord(toolCall)) continue;
const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; const functionPart = isRecord(toolCall.function) ? toolCall.function : {};
const mcpItem = decodeResponsesMcpCompatToolCall(
functionPart.name ?? toolCall.name,
functionPart.arguments ?? toolCall.arguments,
);
if (mcpItem) {
inputItems.push(mcpItem);
continue;
}
const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`;
const name = ( const name = (
asTrimmedString(functionPart.name) asTrimmedString(functionPart.name)
@@ -810,6 +826,15 @@ export function convertResponsesBodyToOpenAiBody(
if (!isRecord(item)) return; if (!isRecord(item)) return;
const itemType = asTrimmedString(item.type).toLowerCase(); const itemType = asTrimmedString(item.type).toLowerCase();
if (itemType.startsWith('mcp_') && isResponsesMcpItem(item)) {
const toolCall = toResponsesMcpCompatToolCall(item, `call_${Date.now()}_${functionCallIndex}`);
if (toolCall) {
pendingToolCalls.push(toolCall as OpenAiToolCall);
functionCallIndex += 1;
return;
}
}
if (itemType === 'function_call' || itemType === 'custom_tool_call') { if (itemType === 'function_call' || itemType === 'custom_tool_call') {
const toolCall = toOpenAiToolCall(item, functionCallIndex); const toolCall = toOpenAiToolCall(item, functionCallIndex);
functionCallIndex += 1; functionCallIndex += 1;
@@ -858,10 +883,13 @@ export function convertResponsesBodyToOpenAiBody(
: Array.isArray(content) && content.length > 0; : Array.isArray(content) && content.length > 0;
if (!hasContent) return; if (!hasContent) return;
messages.push({ const message: Record<string, unknown> = {
role: normalizedRole, role: normalizedRole,
content, content,
}); };
const phase = asTrimmedString(item.phase);
if (phase) message.phase = phase;
messages.push(message);
}; };
if (typeof input === 'string') { if (typeof input === 'string') {
@@ -0,0 +1,110 @@
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 cloneJsonValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJsonValue(item)) as T;
}
if (isRecord(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
) as T;
}
return value;
}
const RESPONSES_MCP_COMPAT_MARKER = 'responses_mcp_item';
const RESPONSES_MCP_COMPAT_PREFIX = 'metapi_mcp_item__';
type ResponsesMcpCompatEnvelope = {
metapi_compat: typeof RESPONSES_MCP_COMPAT_MARKER;
itemType: string;
item: Record<string, unknown>;
};
export function isResponsesMcpItem(item: unknown): item is Record<string, unknown> {
return isRecord(item) && asTrimmedString(item.type).toLowerCase().startsWith('mcp_');
}
function createResponsesMcpCompatEnvelope(
item: Record<string, unknown>,
): ResponsesMcpCompatEnvelope | null {
const itemType = asTrimmedString(item.type).toLowerCase();
if (!itemType.startsWith('mcp_')) return null;
return {
metapi_compat: RESPONSES_MCP_COMPAT_MARKER,
itemType,
item: cloneJsonValue(item),
};
}
export function toResponsesMcpCompatToolName(itemType: string): string {
const normalizedType = asTrimmedString(itemType).toLowerCase();
return `${RESPONSES_MCP_COMPAT_PREFIX}${normalizedType || 'item'}`;
}
export function toResponsesMcpCompatToolCall(
item: Record<string, unknown>,
fallbackId: string,
): Record<string, unknown> | null {
const envelope = createResponsesMcpCompatEnvelope(item);
if (!envelope) return null;
const id = (
asTrimmedString(item.call_id)
|| asTrimmedString(item.id)
|| asTrimmedString(item.approval_request_id)
|| fallbackId
);
return {
id,
type: 'function',
function: {
name: toResponsesMcpCompatToolName(envelope.itemType),
arguments: JSON.stringify(envelope),
},
};
}
function parseCompatEnvelope(rawArguments: unknown): ResponsesMcpCompatEnvelope | null {
if (typeof rawArguments !== 'string') return null;
const trimmed = rawArguments.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!isRecord(parsed)) return null;
if (asTrimmedString(parsed.metapi_compat) !== RESPONSES_MCP_COMPAT_MARKER) return null;
const itemType = asTrimmedString(parsed.itemType).toLowerCase();
if (!itemType.startsWith('mcp_')) return null;
if (!isRecord(parsed.item)) return null;
return {
metapi_compat: RESPONSES_MCP_COMPAT_MARKER,
itemType,
item: cloneJsonValue(parsed.item),
};
} catch {
return null;
}
}
export function decodeResponsesMcpCompatToolCall(
toolName: unknown,
rawArguments: unknown,
): Record<string, unknown> | null {
const normalizedName = asTrimmedString(toolName).toLowerCase();
if (!normalizedName.startsWith(RESPONSES_MCP_COMPAT_PREFIX)) return null;
const envelope = parseCompatEnvelope(rawArguments);
if (!envelope) return null;
return envelope.item;
}
@@ -254,6 +254,52 @@ describe('serializeResponsesFinalPayload', () => {
]); ]);
}); });
it('restores mcp items from compatibility tool calls when serializing fallback response payloads', () => {
const mcpCall = {
type: 'mcp_call',
id: 'mcp_call_1',
call_id: 'mcp_call_1',
name: 'read_file',
server_label: 'filesystem',
arguments: {
path: '/tmp/demo.txt',
},
};
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
id: 'opaque_mcp_1',
model: 'gpt-5',
},
normalized: {
id: 'opaque_mcp_1',
model: 'gpt-5',
created: 1700000000,
content: '',
reasoningContent: '',
finishReason: 'tool_calls',
toolCalls: [
{
id: 'mcp_call_1',
name: 'metapi_mcp_item__mcp_call',
arguments: JSON.stringify({
metapi_compat: 'responses_mcp_item',
itemType: 'mcp_call',
item: mcpCall,
}),
},
],
},
usage: {
promptTokens: 1,
completionTokens: 2,
totalTokens: 3,
},
});
expect(payload.output).toEqual([mcpCall]);
});
it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => { it('preserves response-like custom tool and image generation items when synthesizing object=response payloads', () => {
const payload = serializeResponsesFinalPayload({ const payload = serializeResponsesFinalPayload({
upstreamPayload: { upstreamPayload: {
@@ -3,6 +3,7 @@ import {
type NormalizedFinalResponse, type NormalizedFinalResponse,
} from '../../shared/normalized.js'; } from '../../shared/normalized.js';
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js'; import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js';
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
@@ -228,6 +229,10 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut
}; };
} }
if (itemType.startsWith('mcp_')) {
return cloneJson(rawItem);
}
return null; return null;
}) })
.filter((item): item is ResponsesOutputItem => item !== null); .filter((item): item is ResponsesOutputItem => item !== null);
@@ -394,6 +399,12 @@ export function serializeResponsesFinalPayload(input: {
if (toolCalls.length > 0 && !hasToolLikeItem) { if (toolCalls.length > 0 && !hasToolLikeItem) {
for (const toolCall of toolCalls) { for (const toolCall of toolCalls) {
const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments);
if (mcpItem) {
output.push(mcpItem);
continue;
}
output.push({ output.push({
id: toolCall.id, id: toolCall.id,
type: 'function_call', type: 'function_call',
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js'; import { createStreamTransformContext, normalizeUpstreamStreamEvent, convertClaudeRequestToOpenAiBody } from './chatFormatsCore.js';
describe('chatFormatsCore inline think parsing', () => { describe('chatFormatsCore inline think parsing', () => {
it('tracks split think tags across stream chunks', () => { it('tracks split think tags across stream chunks', () => {
@@ -67,3 +67,50 @@ describe('chatFormatsCore inline think parsing', () => {
}); });
}); });
}); });
describe('convertClaudeRequestToOpenAiBody', () => {
it('keeps Claude tool_result content structured when a tool produces image blocks', () => {
const payload = {
model: 'gpt-test',
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call-image',
name: 'ImageTool',
input: { query: 'cat' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call-image',
content: [
{ type: 'text', text: 'found 1' },
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/cat.png',
media_type: 'image/png',
},
},
],
},
],
},
],
};
const { messages } = convertClaudeRequestToOpenAiBody(payload);
const toolMessage = messages.find((message) => message.role === 'tool');
expect(toolMessage).toBeTruthy();
expect(Array.isArray(toolMessage?.content)).toBe(true);
expect(toolMessage?.content.some((part: any) => part?.type === 'image_url')).toBe(true);
});
});
@@ -778,7 +778,7 @@ function extractClaudeReasoningRequest(
}; };
} }
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): { export function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
model: string; model: string;
stream: boolean; stream: boolean;
messages: Array<Record<string, unknown>>; messages: Array<Record<string, unknown>>;
@@ -789,6 +789,42 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
const messages: Array<Record<string, unknown>> = []; const messages: Array<Record<string, unknown>> = [];
const convertToolResultContent = (content: unknown): string | Array<Record<string, unknown>> | null => {
const blocks: Array<Record<string, unknown>> = [];
const appendContentBlock = (item: unknown) => {
if (isRecord(item)) {
const block = toOpenAiContentBlockFromClaudeBlock(item);
if (block) {
blocks.push(block);
return;
}
}
const text = parseClaudeMessageContent(item);
if (text) {
blocks.push({ type: 'text', text });
}
};
const valueToProcess = (isRecord(content) && Array.isArray(content.content))
? (content.content as unknown[])
: content;
if (Array.isArray(valueToProcess)) {
for (const item of valueToProcess) {
appendContentBlock(item);
}
} else {
appendContentBlock(valueToProcess);
}
if (blocks.length <= 0) return null;
const collapsed = collapseOpenAiContentBlocks(blocks);
if (!collapsed) return null;
if (typeof collapsed === 'string' && collapsed.length === 0) return null;
return collapsed;
};
const appendMessage = (role: string, content: unknown) => { const appendMessage = (role: string, content: unknown) => {
const text = parseClaudeMessageContent(content); const text = parseClaudeMessageContent(content);
if (!text) return; if (!text) return;
@@ -799,16 +835,13 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : ''; const toolCallId = typeof toolUseId === 'string' ? toolUseId.trim() : '';
if (!toolCallId) return; if (!toolCallId) return;
const text = ( const contentPayload = convertToolResultContent(content);
parseClaudeMessageContent(content) if (!contentPayload) return;
|| stringifyUnknownValue(content)
).trim();
if (!text) return;
messages.push({ messages.push({
role: 'tool', role: 'tool',
tool_call_id: toolCallId, tool_call_id: toolCallId,
content: text, content: contentPayload,
}); });
}; };