Harden cross-protocol request compatibility

Several downstream clients send non-cache compatibility fields across Chat, Responses, Anthropic Messages, and Responses WebSocket surfaces. The proxy now normalizes legacy functions/function_call, maps web_search tools across protocols, rejects unsupported HTTP Responses continuation shapes before upstream dispatch, and applies configurable service_tier policy consistently before requests leave the server.

Constraint: Publication flow keeps day-to-day commits on dev and opens main PRs from a release branch.

Constraint: No new dependencies; web_search-only simulation reuses the existing local search route.

Rejected: Forward external HTTP previous_response_id to strict upstreams | it produces ambiguous continuation failures and stale retry behavior outside WebSocket state.

Rejected: Add a dedicated search dependency | existing /v1/search route is sufficient for synthetic server-tool responses.

Confidence: high

Scope-risk: moderate

Directive: Keep Responses HTTP continuation checks aligned with WebSocket continuation support before relaxing these guards.

Tested: npm run typecheck:server

Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/test-fixtures/protocol/non-cache-field-matrix.test.ts src/server/transformers/openai/responses/conversion.test.ts src/server/transformers/anthropic/messages/conversion.test.ts src/server/routes/proxy/chat.stream.test.ts src/server/routes/proxy/responses.codex-oauth.test.ts

Tested: npx vitest run --root . src/server/routes/proxy/responses.websocket.test.ts

Tested: npm run repo:drift-check

Not-tested: Full application build and full Vitest suite

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
cita-777
2026-05-08 00:30:48 +08:00
parent c94e3148dd
commit 2491a9af5a
18 changed files with 1701 additions and 71 deletions
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
convertAnthropicToolsToOpenAi,
convertOpenAiBodyToAnthropicMessagesBody,
convertOpenAiToolChoiceToAnthropic,
convertOpenAiToolsToAnthropic,
sanitizeAnthropicMessagesBody,
} from './conversion.js';
import { anthropicMessagesInbound } from './inbound.js';
@@ -29,6 +31,24 @@ describe('sanitizeAnthropicMessagesBody', () => {
});
});
it('maps web_search server tools between OpenAI-compatible and Anthropic Messages requests', () => {
expect(convertOpenAiToolsToAnthropic([
{ type: 'web_search', max_results: 3 },
{ type: 'google_search' },
])).toEqual([
{ type: 'web_search_20250305', max_results: 3, name: 'web_search' },
{ type: 'web_search_20250305', name: 'web_search' },
]);
expect(convertAnthropicToolsToOpenAi([
{ type: 'web_search_20250305', max_uses: 2 },
{ name: 'lookup_weather', input_schema: { type: 'object' } },
])).toEqual([
{ type: 'web_search', max_uses: 2, name: 'web_search' },
{ name: 'lookup_weather', input_schema: { type: 'object' } },
]);
});
it('normalizes string system and message content before rebuilding cache anchors', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
@@ -12,6 +12,11 @@ const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
const ANTHROPIC_WEB_SEARCH_TOOL_TYPES = new Set([
'web_search',
'web_search_20250305',
'google_search',
]);
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
@@ -181,6 +186,20 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
return '';
}
function isAnthropicWebSearchTool(value: Record<string, unknown>): boolean {
const type = asTrimmedString(value.type).toLowerCase();
const name = asTrimmedString(value.name).toLowerCase();
return ANTHROPIC_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
}
function convertAnthropicWebSearchToolToOpenAi(value: Record<string, unknown>): Record<string, unknown> {
const next: Record<string, unknown> = { ...value };
const type = asTrimmedString(value.type).toLowerCase();
next.type = type === 'google_search' ? 'web_search' : 'web_search';
next.name = 'web_search';
return next;
}
export function normalizeAnthropicToolInput(raw: unknown): unknown {
if (raw === undefined || raw === null) return {};
if (isRecord(raw) || Array.isArray(raw)) return raw;
@@ -802,6 +821,14 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
if (!isRecord(item)) return null;
const type = asTrimmedString(item.type).toLowerCase();
if (isAnthropicWebSearchTool(item)) {
return {
...item,
type: 'web_search_20250305',
name: 'web_search',
};
}
if (type === 'function' && isRecord(item.function)) {
const fn = item.function;
const name = asTrimmedString(fn.name);
@@ -826,6 +853,16 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
return converted.length > 0 ? converted : rawTools;
}
export function convertAnthropicToolsToOpenAi(rawTools: unknown): unknown {
if (!Array.isArray(rawTools)) return rawTools;
return rawTools.map((item) => {
if (!isRecord(item)) return item;
if (isAnthropicWebSearchTool(item)) return convertAnthropicWebSearchToolToOpenAi(item);
return item;
});
}
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
if (rawToolChoice === undefined) return undefined;
@@ -1059,6 +1059,79 @@ describe('convertOpenAiBodyToResponsesBody', () => {
});
});
it('maps legacy chat functions, function_call and role:function messages into Responses request fields', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [
{ role: 'user', content: 'lookup weather' },
{ role: 'function', name: 'legacy_weather', content: '{"temp":21}' },
],
functions: [
{
name: 'legacy_weather',
description: 'Lookup weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
},
},
],
function_call: { name: 'legacy_weather' },
},
'gpt-5',
false,
);
expect(result.tools).toEqual([
{
type: 'function',
name: 'legacy_weather',
description: 'Lookup weather',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
},
},
]);
expect(result.tool_choice).toEqual({
type: 'function',
name: 'legacy_weather',
});
expect(result.input).toEqual([
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'lookup weather' }],
},
{
type: 'function_call_output',
call_id: 'legacy_weather',
output: '{"temp":21}',
},
]);
});
it('preserves OpenAI web_search and google_search server tools in Responses request bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'search docs' }],
tools: [
{ type: 'web_search_preview', search_context_size: 'low' },
{ type: 'google_search', max_results: 3 },
],
},
'gpt-5',
false,
);
expect(result.tools).toEqual([
{ type: 'web_search_preview', search_context_size: 'low', name: 'web_search' },
{ type: 'web_search', max_results: 3, name: 'web_search' },
]);
});
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
@@ -71,6 +71,19 @@ function cloneJsonValue<T>(value: T): T {
return value;
}
const OPENAI_WEB_SEARCH_TOOL_TYPES = new Set([
'web_search',
'web_search_preview',
'web_search_preview_2025_03_11',
'google_search',
]);
function isOpenAiWebSearchToolRecord(item: Record<string, unknown>): boolean {
const type = asTrimmedString(item.type).toLowerCase();
const name = asTrimmedString(item.name).toLowerCase();
return OPENAI_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
}
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
const trimmed = asTrimmedString(value);
return trimmed || undefined;
@@ -287,11 +300,22 @@ function collectOpenAiToolNames(body: Record<string, unknown>): string[] {
}
}
const rawFunctions = Array.isArray(body.functions) ? body.functions : [];
for (const item of rawFunctions) {
if (!isRecord(item)) continue;
pushName(item.name);
}
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
}
const legacyFunctionCall = isRecord(body.function_call) ? body.function_call : null;
if (legacyFunctionCall) {
pushName(legacyFunctionCall.name);
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
for (const message of rawMessages) {
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
@@ -344,6 +368,14 @@ function convertOpenAiToolsToResponses(
return item;
}
if (isOpenAiWebSearchToolRecord(item)) {
return {
...item,
type: type === 'google_search' ? 'web_search' : (type || 'web_search'),
name: asTrimmedString(item.name) || 'web_search',
};
}
if (type === 'custom' && asTrimmedString(item.name)) {
return item;
}
@@ -355,6 +387,43 @@ function convertOpenAiToolsToResponses(
return converted;
}
function convertOpenAiFunctionsToResponses(
rawFunctions: unknown,
toolNameMap: Record<string, string>,
): Array<Record<string, unknown>> {
if (!Array.isArray(rawFunctions)) return [];
return rawFunctions
.map((item) => {
if (!isRecord(item)) return null;
const name = asTrimmedString(item.name);
if (!name) return null;
const mapped: Record<string, unknown> = {
type: 'function',
name: getShortToolName(name, toolNameMap),
};
const description = asTrimmedString(item.description);
if (description) mapped.description = description;
if (item.parameters !== undefined) mapped.parameters = item.parameters;
if (item.strict !== undefined) mapped.strict = item.strict;
return mapped;
})
.filter((item): item is Record<string, unknown> => !!item);
}
function mergeOpenAiToolsAndFunctionsToResponses(
rawTools: unknown,
rawFunctions: unknown,
toolNameMap: Record<string, string>,
): unknown {
const convertedTools = convertOpenAiToolsToResponses(rawTools, toolNameMap);
const toolList = Array.isArray(convertedTools) ? convertedTools : [];
const functionList = convertOpenAiFunctionsToResponses(rawFunctions, toolNameMap);
if (toolList.length <= 0 && functionList.length <= 0) return convertedTools;
return [...toolList, ...functionList];
}
function convertOpenAiToolChoiceToResponses(
rawToolChoice: unknown,
toolNameMap: Record<string, string>,
@@ -373,6 +442,26 @@ function convertOpenAiToolChoiceToResponses(
return rawToolChoice;
}
function convertOpenAiFunctionCallToResponsesToolChoice(
rawFunctionCall: unknown,
toolNameMap: Record<string, string>,
): unknown {
if (rawFunctionCall === undefined) return undefined;
if (typeof rawFunctionCall === 'string') {
const normalized = rawFunctionCall.trim().toLowerCase();
if (normalized === 'none' || normalized === 'auto' || normalized === 'required') return normalized;
return rawFunctionCall;
}
if (!isRecord(rawFunctionCall)) return rawFunctionCall;
const name = asTrimmedString(rawFunctionCall.name);
if (!name) return 'required';
return {
type: 'function',
name: getShortToolName(name, toolNameMap),
};
}
function normalizeResponsesBodyForCompatibility(
body: Record<string, unknown>,
): Record<string, unknown> {
@@ -596,6 +685,23 @@ export function convertOpenAiBodyToResponsesBody(
continue;
}
if (role === 'function') {
const callId = asTrimmedString(item.name) || asTrimmedString(item.id);
if (!callId) continue;
const output = normalizeToolOutput(item.content);
inputItems.push({
type: 'function_call_output',
call_id: callId,
output: (
(typeof output === 'string' && output === '')
|| (Array.isArray(output) && output.length === 0)
)
? '(empty)'
: output,
});
continue;
}
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
if (normalizedContent.length <= 0) continue;
inputItems.push({
@@ -642,7 +748,13 @@ export function convertOpenAiBodyToResponsesBody(
if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget;
if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary;
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
if (openaiBody.tools !== undefined || openaiBody.functions !== undefined) {
body.tools = mergeOpenAiToolsAndFunctionsToResponses(
openaiBody.tools,
openaiBody.functions,
toolNameMap,
);
}
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
@@ -668,7 +780,9 @@ export function convertOpenAiBodyToResponsesBody(
body.text = textConfig;
}
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
const responsesToolChoice = openaiBody.tool_choice !== undefined
? convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap)
: convertOpenAiFunctionCallToResponsesToolChoice(openaiBody.function_call, toolNameMap);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
if (Array.isArray(body.tools) && body.tools.length === 0) {
delete body.tool_choice;
@@ -973,11 +973,25 @@ function convertClaudeToolsToOpenAiChat(rawTools: unknown): unknown {
if (!isRecord(item)) return item;
const type = asTrimmedString(item.type).toLowerCase();
const name = asTrimmedString(item.name);
if (
type === 'web_search'
|| type === 'web_search_20250305'
|| type === 'google_search'
|| name === 'web_search'
|| name === 'google_search'
) {
return {
...item,
type: 'web_search',
name: 'web_search',
};
}
if (type === 'function' || type === 'custom' || type === 'image_generation') {
return item;
}
const name = asTrimmedString(item.name);
if (!name) return item;
return {