diff --git a/changelog.d/fixes/9161-codex-responses-chat-targets.md b/changelog.d/fixes/9161-codex-responses-chat-targets.md new file mode 100644 index 0000000000..2fff43b6e8 --- /dev/null +++ b/changelog.d/fixes/9161-codex-responses-chat-targets.md @@ -0,0 +1 @@ +- **fix(translator):** Honor configured Chat targets for Responses-shaped clients while preserving native Responses providers and outbound token fields ([#9161](https://github.com/diegosouzapw/OmniRoute/pull/9161)) — thanks @Zartharas diff --git a/open-sse/handlers/chatCore/sanitization.ts b/open-sse/handlers/chatCore/sanitization.ts index b93f3ac2c3..f36fcc02be 100644 --- a/open-sse/handlers/chatCore/sanitization.ts +++ b/open-sse/handlers/chatCore/sanitization.ts @@ -6,8 +6,8 @@ export function sanitizeChatRequestBody( sourceFormat: string, targetFormat: string ): Record { - const prefersResponsesTokenField = - sourceFormat === FORMATS.OPENAI_RESPONSES || targetFormat === FORMATS.OPENAI_RESPONSES; + void sourceFormat; + const prefersResponsesTokenField = targetFormat === FORMATS.OPENAI_RESPONSES; if (prefersResponsesTokenField) { if (body.max_output_tokens === undefined) { diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index cde4be6162..991a5c3eb2 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -2,13 +2,12 @@ * chatCore wire target-format resolver (Quality Gate v2 / Fase 9 — chatCore god-file * decomposition, #3501). * - * Pure resolution of the provider alias + the upstream target format used to translate the request: - * apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then - * the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the - * connection has no explicit override, then the provider default. Returns both `alias` (reused by - * the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`. - * Side-effect-free; sits alongside the other request-setup resolvers - * (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat). + * Pure resolution of the provider alias + the upstream target format used to translate the request. + * Model/custom overrides win first. A Responses-shaped inbound request normally keeps the Responses + * wire format, except for custom OpenAI-compatible connections explicitly configured for Chat. + * AgentRouter may inherit the inbound protocol when no explicit connection override exists. + * Returns both `alias` (reused by the handler when stripping the `alias/` prefix off the upstream + * model id) and `targetFormat`. */ import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts"; @@ -46,15 +45,19 @@ export function resolveChatCoreTargetFormat(opts: { sourceFormat === FORMATS.CLAUDE) ? sourceFormat : undefined; + const providerTargetFormat = getTargetFormat(provider, providerSpecificData); + const customOpenAICompatible = provider.startsWith("openai-compatible-"); // #8994: model-level targetFormat overrides (from registry or custom-model DB override) // take precedence over apiFormat="responses" — otherwise Vertex Claude models with // targetFormat="claude" get wrongly routed to OpenAI Responses format. + // #9161: a custom OpenAI-compatible Chat connection must likewise keep its configured + // outbound protocol when a Responses-shaped client (for example Codex) calls /responses. let targetFormat = modelTargetFormat || customModelTargetFormat || - (apiFormat === "responses" + (apiFormat === "responses" && !customOpenAICompatible ? FORMATS.OPENAI_RESPONSES - : inferredAgentRouterTargetFormat || getTargetFormat(provider, providerSpecificData)); + : inferredAgentRouterTargetFormat || providerTargetFormat); if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES; return { alias, targetFormat }; } diff --git a/tests/unit/codex-responses-to-chat-9161.test.ts b/tests/unit/codex-responses-to-chat-9161.test.ts new file mode 100644 index 0000000000..a4f4d5a5eb --- /dev/null +++ b/tests/unit/codex-responses-to-chat-9161.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; +import { sanitizeChatRequestBody } from "../../open-sse/handlers/chatCore/sanitization.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +test("#9161 Responses client honors a custom OpenAI-compatible Chat target", () => { + const result = resolveChatCoreTargetFormat({ + provider: "openai-compatible-chat-regression", + resolvedModel: "custom-chat-model", + apiFormat: "responses", + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: { apiType: "chat" }, + }); + + assert.equal(result.targetFormat, FORMATS.OPENAI); +}); + +test("#9161 Responses client preserves a custom OpenAI-compatible Responses target", () => { + const result = resolveChatCoreTargetFormat({ + provider: "openai-compatible-responses-regression", + resolvedModel: "custom-responses-model", + apiFormat: "responses", + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: { apiType: "responses" }, + }); + + assert.equal(result.targetFormat, FORMATS.OPENAI_RESPONSES); +}); + +test("#9161 official OpenAI keeps Responses routing for the Responses API", () => { + const result = resolveChatCoreTargetFormat({ + provider: "openai", + resolvedModel: "gpt-4o", + apiFormat: "responses", + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: undefined, + }); + + assert.equal(result.targetFormat, FORMATS.OPENAI_RESPONSES); +}); + +test("#9161 Responses source targeting Chat converts max_output_tokens to max_tokens", () => { + const body = sanitizeChatRequestBody( + { max_output_tokens: 256 }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ); + + assert.equal(body.max_tokens, 256); + assert.equal(body.max_output_tokens, undefined); +}); + +test("#9161 Responses target keeps the Responses token field", () => { + const body = sanitizeChatRequestBody( + { max_tokens: 128 }, + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES + ); + + assert.equal(body.max_output_tokens, 128); + assert.equal(body.max_tokens, undefined); +});