diff --git a/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts b/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts index 335019cf..3bb65862 100644 --- a/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts +++ b/src/cliproxy/proxy/__tests__/tool-sanitization-proxy-integration.test.ts @@ -436,6 +436,155 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('translates Codex fast aliases before forwarding to provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5-fast', + messages: [], + }), + }); + + const sentBody = lastRequest!.body as Record; + expect(sentBody.model).toBe('gpt-5.5'); + expect(sentBody.service_tier).toBe('priority'); + } finally { + proxy.stop(); + } + }); + + it('translates Codex effort and fast aliases before forwarding to provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5-fast-high', + messages: [], + reasoning: { summary: 'auto' }, + }), + }); + + const sentBody = lastRequest!.body as Record; + expect(sentBody.model).toBe('gpt-5.5'); + expect((sentBody.reasoning as Record).summary).toBe('auto'); + expect((sentBody.reasoning as Record).effort).toBe('high'); + expect(sentBody.service_tier).toBe('priority'); + } finally { + proxy.stop(); + } + }); + + it('folds Codex system messages into the first user message before forwarding', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages?beta=true`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + system: [{ type: 'text', text: 'Always be concise.' }], + messages: [ + { role: 'system', content: 'Use JSON.' }, + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ], + }), + }); + + const sentBody = lastRequest!.body as Record; + const sentMessages = sentBody.messages as Array>; + + expect(sentBody.system).toBeUndefined(); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0].role).toBe('user'); + expect(sentMessages[0].content).toEqual([ + { type: 'text', text: 'Always be concise.\n\nUse JSON.' }, + { type: 'text', text: 'hello' }, + ]); + } finally { + proxy.stop(); + } + }); + + it('strips blank Codex system messages before forwarding', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + system: ' ', + messages: [ + { role: 'system', content: [{ type: 'text', text: ' ' }] }, + { role: 'user', content: 'hello' }, + ], + }), + }); + + const sentBody = lastRequest!.body as Record; + const sentMessages = sentBody.messages as Array>; + + expect(sentBody.system).toBeUndefined(); + expect(sentMessages).toEqual([{ role: 'user', content: 'hello' }]); + } finally { + proxy.stop(); + } + }); + + it('does not apply Codex alias or system rewrites on explicit non-Codex provider routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.5-fast', + system: 'Keep this as a provider-level system field.', + messages: [ + { role: 'system', content: 'Keep this as a message.' }, + { role: 'user', content: 'hello' }, + ], + }), + }); + + const sentBody = lastRequest!.body as Record; + expect(sentBody.model).toBe('gpt-5.5-fast'); + expect(sentBody.service_tier).toBeUndefined(); + expect(sentBody.system).toBe('Keep this as a provider-level system field.'); + expect(sentBody.messages).toEqual([ + { role: 'system', content: 'Keep this as a message.' }, + { role: 'user', content: 'hello' }, + ]); + } finally { + proxy.stop(); + } + }); + for (const model of [ 'gpt-5.3-codex-xhigh', 'gpt-5.1-codex-mini', diff --git a/src/cliproxy/proxy/tool-sanitization-proxy.ts b/src/cliproxy/proxy/tool-sanitization-proxy.ts index a49ca71a..a86cc7ad 100644 --- a/src/cliproxy/proxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/proxy/tool-sanitization-proxy.ts @@ -22,6 +22,7 @@ import { extractProviderFromPathname, getDeniedModelIdReasonForProvider, normalizeModelIdForRouting, + parseCodexModelTuningAlias, stripCodexEffortSuffix, } from '../ai-providers/model-id-normalizer'; import { getModelMaxLevel } from '../model-catalog'; @@ -63,6 +64,7 @@ const GEMINI_UNSUPPORTED_TOOL_FIELDS = new Set([ ]); const CODEX_UNSUPPORTED_TOOL_FIELDS = new Set(['cache_control']); +const CODEX_FAST_SERVICE_TIER = 'priority'; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; const LEGACY_CODEX_MODEL_ID_REGEX = /^gpt-5(?:\.\d+)?-codex(?:-(?:mini|max))?$/i; @@ -90,6 +92,118 @@ function isKnownCodexModelId(model: string | undefined): boolean { ); } +function isCodexRequest(providerFromPath: string | null, model: unknown): boolean { + return ( + providerFromPath === 'codex' || + (providerFromPath === null && typeof model === 'string' && isKnownCodexModelId(model)) + ); +} + +function applyCodexModelTuningAlias(body: Record): Record { + if (typeof body.model !== 'string') { + return body; + } + + const parsed = parseCodexModelTuningAlias(body.model); + if (!parsed || !isKnownCodexModelId(parsed.baseModel)) { + return body; + } + + const tunedBody: Record = { ...body, model: parsed.baseModel }; + + if (parsed.effort) { + const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {}; + tunedBody.reasoning = { + ...existingReasoning, + effort: parsed.effort, + }; + } + + if (parsed.serviceTier) { + tunedBody.service_tier = CODEX_FAST_SERVICE_TIER; + } + + return tunedBody; +} + +function extractSystemText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (!Array.isArray(content)) { + return ''; + } + + return content + .filter((block): block is { type: unknown; text?: unknown } => isRecord(block)) + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text as string) + .join('\n\n'); +} + +function prependSystemTextToContent(content: unknown, systemText: string): unknown { + if (Array.isArray(content)) { + return [{ type: 'text', text: systemText }, ...content]; + } + if (typeof content === 'string') { + return `${systemText}\n\n${content}`; + } + return systemText; +} + +function foldCodexSystemMessages(body: Record): Record { + const systemTexts: string[] = []; + let removedSystem = false; + const nextBody = { ...body }; + + if (body.system !== undefined) { + removedSystem = true; + delete nextBody.system; + const systemText = extractSystemText(body.system).trim(); + if (systemText) { + systemTexts.push(systemText); + } + } + + const rawMessages = Array.isArray(body.messages) ? body.messages : []; + const messages = rawMessages.filter((message) => { + if (!isRecord(message) || message.role !== 'system') { + return true; + } + removedSystem = true; + const systemText = extractSystemText(message.content).trim(); + if (systemText) { + systemTexts.push(systemText); + } + return false; + }); + + if (!removedSystem) { + return body; + } + + if (systemTexts.length > 0) { + const systemPrefix = systemTexts.join('\n\n'); + const firstUserIndex = messages.findIndex( + (message) => isRecord(message) && message.role === 'user' + ); + + if (firstUserIndex >= 0 && isRecord(messages[firstUserIndex])) { + const firstUserMessage = messages[firstUserIndex]; + messages[firstUserIndex] = { + ...firstUserMessage, + content: prependSystemTextToContent(firstUserMessage.content, systemPrefix), + }; + } else { + messages.unshift({ role: 'user', content: systemPrefix }); + } + } + + nextBody.messages = messages; + return nextBody; +} + function getUnsupportedToolFields( providerFromPath: string | null, model: string | undefined @@ -352,6 +466,11 @@ export class ToolSanitizationProxy { } } + if (isRecord(modifiedBody) && isCodexRequest(providerFromPath, modifiedBody.model)) { + const tunedBody = applyCodexModelTuningAlias(modifiedBody); + modifiedBody = foldCodexSystemMessages(tunedBody); + } + // Sanitize tools if present if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) { // Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)