From cf7f684bd8823d517f5eb026603c499aa30ca7ef Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:16:10 -0300 Subject: [PATCH] fix(api): don't force combo names to codex/ on /v1/responses (#3227, #3233) (#3244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex CLI WS->HTTP fallback rewrite (resolveResponsesApiModel) prefixes a bare model id with codex/ whenever codex/ resolves to codex. Codex accepts arbitrary model strings, so a combo name with no slash (e.g. n8n-text, paid-premium) was rewritten to codex/ and sent to Codex instead of being resolved as a combo — regressing combos via /v1/responses in v3.8.9+. Skip the rewrite when the bare id is a combo (getComboByName). + unit test. --- .../codex-responses-ws/modelResolution.ts | 16 ++++++- src/app/api/v1/responses/route.ts | 7 ++- .../responses-combo-resolution-3227.test.ts | 45 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/unit/responses-combo-resolution-3227.test.ts diff --git a/src/app/api/internal/codex-responses-ws/modelResolution.ts b/src/app/api/internal/codex-responses-ws/modelResolution.ts index 3011c8adbf..a6b6d85f1f 100644 --- a/src/app/api/internal/codex-responses-ws/modelResolution.ts +++ b/src/app/api/internal/codex-responses-ws/modelResolution.ts @@ -58,17 +58,31 @@ export async function resolveCodexWsModelInfo( * * @param requestedModel the model id from the Responses API request body * @param resolve a getModelInfo-style resolver + * @param isCombo optional predicate — when the bare id is a combo name, skip the codex + * rewrite so downstream combo routing resolves it (#3227/#3233). * @returns { model, changed } — model is the (possibly rewritten) id; * changed=true means a codex/ prefix was applied. */ export async function resolveResponsesApiModel( requestedModel: string, - resolve: ModelResolver + resolve: ModelResolver, + isCombo?: (name: string) => Promise | boolean ): Promise<{ model: string; changed: boolean }> { if (!requestedModel || requestedModel.includes("/")) { return { model: requestedModel, changed: false }; } + // #3227/#3233: a bare combo name (e.g. "n8n-text", "paid-premium") must NOT be + // force-prefixed to codex/ — Codex accepts arbitrary model strings, so the rewrite + // would shadow the combo and route to codex. Let downstream combo routing handle it. + if (isCombo) { + try { + if (await isCombo(requestedModel)) return { model: requestedModel, changed: false }; + } catch { + // combo lookup unavailable — fall through to normal codex-preference resolution + } + } + try { const resolved = await resolveCodexWsModelInfo(requestedModel, resolve); if (resolved?.provider !== "codex") { diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index b7445152ed..06804866c1 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -2,6 +2,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; import { resolveResponsesApiModel } from "@/app/api/internal/codex-responses-ws/modelResolution"; import { getModelInfo } from "@/sse/services/model"; +import { getComboByName } from "@/lib/db/combos"; // NOTE: We do NOT call initTranslators() here — the translator registry is // bootstrapped at module level inside open-sse/translator/index.ts when it @@ -37,7 +38,11 @@ async function withCodexPreferredModel(request: Request): Promise { if (!body || typeof body !== "object" || typeof body.model !== "string") { return request; } - const { model, changed } = await resolveResponsesApiModel(body.model, getModelInfo); + const { model, changed } = await resolveResponsesApiModel( + body.model, + getModelInfo, + async (name) => !!(await getComboByName(name)) + ); if (!changed) return request; return new Request(request.url, { diff --git a/tests/unit/responses-combo-resolution-3227.test.ts b/tests/unit/responses-combo-resolution-3227.test.ts new file mode 100644 index 0000000000..f02992e35e --- /dev/null +++ b/tests/unit/responses-combo-resolution-3227.test.ts @@ -0,0 +1,45 @@ +/** + * #3227 / #3233 — combo names broke on /v1/responses in v3.8.9+. + * + * The Codex CLI WS→HTTP fallback added `resolveResponsesApiModel`, which rewrites a bare + * model id to `codex/` whenever `codex/` resolves to the codex provider. Codex + * accepts ANY model string, so a *combo* name with no slash (e.g. `n8n-text`, + * `paid-premium`) was force-rewritten to `codex/` and sent to Codex — + * "No credentials for provider: codex" / "model not supported" — instead of being + * resolved as a combo. The fix skips the codex rewrite when the bare name is a combo. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveResponsesApiModel } from "../../src/app/api/internal/codex-responses-ws/modelResolution.ts"; + +// Resolver where bare ids resolve to a non-codex default, but codex accepts anything +// (mirrors real getModelInfo: the codex provider passes arbitrary model strings). +const resolver = async (modelStr: string) => { + if (modelStr.startsWith("codex/")) return { provider: "codex", model: modelStr.slice(6) }; + if (modelStr === "gpt-5.5") return { provider: "openrouter", model: "gpt-5.5" }; + return { provider: "openrouter", model: modelStr }; +}; + +test("a bare combo name is NOT rewritten to codex/ (it must resolve as a combo)", async () => { + const isCombo = async (name: string) => name === "n8n-text" || name === "paid-premium"; + + for (const combo of ["n8n-text", "paid-premium"]) { + const out = await resolveResponsesApiModel(combo, resolver, isCombo); + assert.equal(out.changed, false, `${combo} must not be codex-prefixed`); + assert.equal(out.model, combo); + } +}); + +test("a bare ChatGPT model id is still codex-preferred (Codex CLI WS→HTTP fallback preserved)", async () => { + const isCombo = async () => false; + const out = await resolveResponsesApiModel("gpt-5.5", resolver, isCombo); + assert.equal(out.changed, true); + assert.equal(out.model, "codex/gpt-5.5"); +}); + +test("provider-prefixed ids pass through unchanged", async () => { + const out = await resolveResponsesApiModel("anthropic/claude-opus-4-8", resolver, async () => false); + assert.equal(out.changed, false); + assert.equal(out.model, "anthropic/claude-opus-4-8"); +});