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:
@@ -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' },
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user