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
@@ -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', () => {
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
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', () => {
const result = convertResponsesBodyToOpenAiBody(
{
@@ -3,6 +3,11 @@ import {
normalizeResponsesMessageContentBlocks,
normalizeResponsesMessageItem,
} from './normalization.js';
import {
decodeResponsesMcpCompatToolCall,
isResponsesMcpItem,
toResponsesMcpCompatToolCall,
} from './mcpCompatibility.js';
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
@@ -241,9 +246,12 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
return '';
}
function normalizeToolOutput(raw: unknown): string {
const text = extractTextContent(raw).trim();
if (text) return text;
function normalizeToolOutput(raw: unknown): string | Array<string | Record<string, unknown>> {
const normalizedContent = toOpenAiMessageContent(raw);
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 (typeof raw === 'string') return raw;
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
@@ -498,6 +506,14 @@ export function convertOpenAiBodyToResponsesBody(
const toolCall = rawToolCalls[index];
if (!isRecord(toolCall)) continue;
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 name = (
asTrimmedString(functionPart.name)
@@ -810,6 +826,15 @@ export function convertResponsesBodyToOpenAiBody(
if (!isRecord(item)) return;
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') {
const toolCall = toOpenAiToolCall(item, functionCallIndex);
functionCallIndex += 1;
@@ -858,10 +883,13 @@ export function convertResponsesBodyToOpenAiBody(
: Array.isArray(content) && content.length > 0;
if (!hasContent) return;
messages.push({
const message: Record<string, unknown> = {
role: normalizedRole,
content,
});
};
const phase = asTrimmedString(item.phase);
if (phase) message.phase = phase;
messages.push(message);
};
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', () => {
const payload = serializeResponsesFinalPayload({
upstreamPayload: {
@@ -3,6 +3,7 @@ import {
type NormalizedFinalResponse,
} from '../../shared/normalized.js';
import { decodeOpenAiEncryptedReasoning } from '../../shared/reasoningTransport.js';
import { decodeResponsesMcpCompatToolCall } from './mcpCompatibility.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
@@ -228,6 +229,10 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut
};
}
if (itemType.startsWith('mcp_')) {
return cloneJson(rawItem);
}
return null;
})
.filter((item): item is ResponsesOutputItem => item !== null);
@@ -394,6 +399,12 @@ export function serializeResponsesFinalPayload(input: {
if (toolCalls.length > 0 && !hasToolLikeItem) {
for (const toolCall of toolCalls) {
const mcpItem = decodeResponsesMcpCompatToolCall(toolCall.name, toolCall.arguments);
if (mcpItem) {
output.push(mcpItem);
continue;
}
output.push({
id: toolCall.id,
type: 'function_call',