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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user