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 62b43615ed..292d0a4ef6 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 9bb2f0da7a..ee56edd226 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -2,56 +2,31 @@ * 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. + * The inbound API shape does not determine the outbound protocol: the model registry target format, + * per-model custom override (#2905), and provider configuration take precedence. This lets a + * Responses-shaped client request be translated to a Chat Completions-compatible upstream while + * preserving native Responses providers configured with an OpenAI Responses target. + * 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"; import { getTargetFormat } from "../../services/provider.ts"; -import { FORMATS } from "../../translator/formats.ts"; export function resolveChatCoreTargetFormat(opts: { provider: string; resolvedModel: string; apiFormat: string | undefined; - sourceFormat?: string; customModelTargetFormat: string | undefined; providerSpecificData: unknown; }) { - const { - provider, - resolvedModel, - apiFormat, - sourceFormat, - customModelTargetFormat, - providerSpecificData, - } = opts; + const { provider, resolvedModel, customModelTargetFormat, providerSpecificData } = opts; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); - const explicitConnectionTargetFormat = ( - providerSpecificData as { targetFormat?: unknown } | null | undefined - )?.targetFormat; - const inferredAgentRouterTargetFormat = - provider === "agentrouter" && - !(typeof explicitConnectionTargetFormat === "string" && explicitConnectionTargetFormat) && - (sourceFormat === FORMATS.OPENAI_RESPONSES || - sourceFormat === FORMATS.OPENAI || - sourceFormat === FORMATS.CLAUDE) - ? sourceFormat - : undefined; const targetFormat = - apiFormat === "responses" - ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || - customModelTargetFormat || - inferredAgentRouterTargetFormat || - getTargetFormat(provider, providerSpecificData); + modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData); return { alias, targetFormat }; } -export type ChatCoreTargetFormat = ReturnType; +export type ChatCoreTargetFormat = ReturnType; \ No newline at end of file diff --git a/tests/unit/chatcore-extracted-modules-3821.test.ts b/tests/unit/chatcore-extracted-modules-3821.test.ts index 009412f98e..2ebecefe87 100644 --- a/tests/unit/chatcore-extracted-modules-3821.test.ts +++ b/tests/unit/chatcore-extracted-modules-3821.test.ts @@ -18,8 +18,12 @@ import { import { FORMATS } from "../../open-sse/translator/formats.ts"; import { saveIdempotency } from "../../src/lib/idempotencyLayer.ts"; -test("sanitizeChatRequestBody: Chat Completions target maps max_output_tokens → max_tokens", () => { - const out = sanitizeChatRequestBody({ max_output_tokens: 256 }, FORMATS.OPENAI, FORMATS.OPENAI); +test("sanitizeChatRequestBody: Responses source targeting Chat maps max_output_tokens → max_tokens", () => { + const out = sanitizeChatRequestBody( + { max_output_tokens: 256 }, + FORMATS.OPENAI_RESPONSES, + FORMATS.OPENAI + ); assert.equal(out.max_tokens, 256); assert.equal(out.max_output_tokens, undefined); }); @@ -35,7 +39,11 @@ test("sanitizeChatRequestBody: Responses target maps max_completion_tokens → m }); test("sanitizeChatRequestBody: Responses target maps max_tokens → max_output_tokens", () => { - const out = sanitizeChatRequestBody({ max_tokens: 128 }, FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI); + const out = sanitizeChatRequestBody( + { max_tokens: 128 }, + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES + ); assert.equal(out.max_output_tokens, 128); assert.equal(out.max_tokens, undefined); }); diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index 2fb4da01dd..098d349d61 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -1,72 +1,85 @@ // tests/unit/chatcore-target-format.test.ts // Characterization of resolveChatCoreTargetFormat — the wire target-format resolution extracted -// from handleChatCore (chatCore god-file decomposition, #3501). Resolves the provider alias and the -// upstream target format: apiFormat==="responses" forces OpenAI Responses; otherwise the model's -// registry target format, then the custom-model override, then the provider default. Returns both -// `alias` (reused downstream when stripping the alias/ prefix off the upstream model) and -// `targetFormat`. +// from handleChatCore (chatCore god-file decomposition, #3501). The inbound client API shape is +// independent from the outbound provider protocol: model registry metadata, custom-model overrides, +// and provider configuration determine the upstream target format. import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; -import { - PROVIDER_ID_TO_ALIAS, - getModelTargetFormat, -} from "../../open-sse/config/providerModels.ts"; +import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; import { getTargetFormat } from "../../open-sse/services/provider.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; function expected( provider: string, resolvedModel: string, - apiFormat: string | undefined, customModelTargetFormat: string | undefined, providerSpecificData: unknown ) { const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); const targetFormat = - apiFormat === "responses" - ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || - customModelTargetFormat || - getTargetFormat(provider, providerSpecificData); + modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData); return { alias, targetFormat }; } -test("apiFormat='responses' short-circuits to OPENAI_RESPONSES (alias still resolved)", () => { +test("Responses client honors a Chat-compatible upstream target", () => { + const provider = "openai-compatible-chat-regression"; + const r = resolveChatCoreTargetFormat({ + provider, + resolvedModel: "custom-chat-model", + apiFormat: "responses", + customModelTargetFormat: undefined, + providerSpecificData: { apiType: "chat" }, + }); + assert.equal(r.targetFormat, FORMATS.OPENAI); + assert.equal(r.alias, provider); +}); + +test("Responses client preserves a provider configured for native Responses", () => { + const provider = "openai-compatible-responses-regression"; + const r = resolveChatCoreTargetFormat({ + provider, + resolvedModel: "custom-responses-model", + apiFormat: "responses", + customModelTargetFormat: undefined, + providerSpecificData: { apiType: "responses" }, + }); + assert.equal(r.targetFormat, FORMATS.OPENAI_RESPONSES); +}); + +test("model registry native Responses target overrides a Chat provider default", () => { + const model = "gpt-5.6-sol"; + assert.equal(getModelTargetFormat("openai", model), FORMATS.OPENAI_RESPONSES); + const r = resolveChatCoreTargetFormat({ provider: "openai", - resolvedModel: "gpt-4o", + resolvedModel: model, apiFormat: "responses", - sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); assert.equal(r.targetFormat, FORMATS.OPENAI_RESPONSES); - assert.equal(r.alias, PROVIDER_ID_TO_ALIAS["openai"] || "openai"); }); -test("delegates byte-identically for a normal model (no apiFormat / no custom override)", () => { +test("delegates byte-identically for a normal model without a custom override", () => { const r = resolveChatCoreTargetFormat({ provider: "openai", resolvedModel: "gpt-4o", apiFormat: undefined, - sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); - assert.deepEqual(r, expected("openai", "gpt-4o", undefined, undefined, undefined)); + assert.deepEqual(r, expected("openai", "gpt-4o", undefined, undefined)); }); test("customModelTargetFormat is used when the model has no registry target format", () => { const customModel = "totally-unknown-custom-model-xyz"; - // precondition: the registry has no target format for this unknown model assert.ok(!getModelTargetFormat(PROVIDER_ID_TO_ALIAS["openai"] || "openai", customModel)); const r = resolveChatCoreTargetFormat({ provider: "openai", resolvedModel: customModel, - apiFormat: undefined, - sourceFormat: FORMATS.OPENAI, + apiFormat: "responses", customModelTargetFormat: "claude", providerSpecificData: undefined, }); @@ -76,36 +89,25 @@ test("customModelTargetFormat is used when the model has no registry target form test("falls back to getTargetFormat(provider) when neither model nor custom format apply", () => { const customModel = "totally-unknown-custom-model-xyz"; const r = resolveChatCoreTargetFormat({ - provider: "openai", + provider: "openai-compatible-chat-regression", resolvedModel: customModel, - apiFormat: undefined, - sourceFormat: FORMATS.OPENAI, + apiFormat: "responses", customModelTargetFormat: undefined, - providerSpecificData: undefined, + providerSpecificData: { apiType: "chat" }, }); - assert.equal(r.targetFormat, getTargetFormat("openai", undefined)); + assert.equal( + r.targetFormat, + getTargetFormat("openai-compatible-chat-regression", { apiType: "chat" }) + ); }); -test("AgentRouter explicit connection protocol overrides the inferred inbound protocol", () => { - const r = resolveChatCoreTargetFormat({ - provider: "agentrouter", - resolvedModel: "gpt-5.6-sol", - apiFormat: undefined, - sourceFormat: FORMATS.OPENAI_RESPONSES, - customModelTargetFormat: undefined, - providerSpecificData: { targetFormat: FORMATS.CLAUDE }, - }); - assert.equal(r.targetFormat, FORMATS.CLAUDE); -}); - -test("unmapped provider → alias falls back to the provider id", () => { +test("unmapped provider alias falls back to the provider id", () => { const r = resolveChatCoreTargetFormat({ provider: "some-unmapped-provider", resolvedModel: "x", apiFormat: "responses", - sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); assert.equal(r.alias, "some-unmapped-provider"); -}); +}); \ No newline at end of file