From 07d90106685ed8a62745c4711bc1e5522cc041e6 Mon Sep 17 00:00:00 2001 From: Wilson Date: Sat, 6 Jun 2026 00:39:57 -0300 Subject: [PATCH] fix(v1/responses): skip codex rewrite for combo names (#3233, #3227) (#3268) Regression test for the /v1/responses combo-name codex-rewrite guard (#3233, #3227). Integrated into release/v3.8.12. Thanks @wilsonicdev. --- src/app/api/v1/responses/route.ts | 2 +- ...combo-name-codex-responses-rewrite.test.ts | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/unit/combo-name-codex-responses-rewrite.test.ts diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index 06804866c1..b737ae01c1 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -31,7 +31,7 @@ export async function OPTIONS() { * Safe: only rewrites when codex/model is genuinely registered; all other models * pass through unchanged. Errors are caught and the original request is returned. */ -async function withCodexPreferredModel(request: Request): Promise { +export async function withCodexPreferredModel(request: Request): Promise { try { const clone = request.clone(); const body = await clone.json().catch(() => null); diff --git a/tests/unit/combo-name-codex-responses-rewrite.test.ts b/tests/unit/combo-name-codex-responses-rewrite.test.ts new file mode 100644 index 0000000000..588e13580b --- /dev/null +++ b/tests/unit/combo-name-codex-responses-rewrite.test.ts @@ -0,0 +1,93 @@ +/** + * Tests for resolveResponsesApiModel combo-name guard — ensures combo names + * without a "/" are NOT rewritten to codex/ prefix on /v1/responses. + * + * Root cause (#3233): the Codex CLI WS→HTTP fallback rewrites bare model ids + * to codex/ prefix, but combo names like "paid-premium" or "n8n-text" also + * lack a "/" and were incorrectly rewritten to "codex/paid-premium", breaking + * combo resolution and producing "No credentials for provider: codex". + * + * These tests exercise the real production function directly, passing mock + * resolvers as arguments. No module mocking required. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveResponsesApiModel } from "../../src/app/api/internal/codex-responses-ws/modelResolution"; + +const mockModelInfo = new Map(); + +async function mockGetModelInfo(modelStr: string) { + return mockModelInfo.get(modelStr) ?? { provider: null, model: modelStr }; +} + +const mockCombos = new Set(); + +async function mockIsCombo(name: string) { + return mockCombos.has(name); +} + +test("combo name 'paid-premium' is NOT rewritten to codex/ prefix", async () => { + mockModelInfo.clear(); + mockModelInfo.set("paid-premium", { provider: null, model: "paid-premium" }); + mockModelInfo.set("codex/paid-premium", { provider: "codex", model: "paid-premium" }); + mockCombos.clear(); + mockCombos.add("paid-premium"); + + const result = await resolveResponsesApiModel("paid-premium", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "paid-premium", "combo name must pass through unchanged"); + assert.equal(result.changed, false, "combo name must not be marked as changed"); +}); + +test("combo name 'n8n-text' is NOT rewritten to codex/ prefix", async () => { + mockModelInfo.clear(); + mockModelInfo.set("n8n-text", { provider: null, model: "n8n-text" }); + mockModelInfo.set("codex/n8n-text", { provider: "codex", model: "n8n-text" }); + mockCombos.clear(); + mockCombos.add("n8n-text"); + + const result = await resolveResponsesApiModel("n8n-text", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "n8n-text", "combo name must pass through unchanged"); + assert.equal(result.changed, false, "combo name must not be marked as changed"); +}); + +test("bare gpt-5.5 without combo is still rewritten to codex/gpt-5.5", async () => { + mockModelInfo.clear(); + mockModelInfo.set("gpt-5.5", { provider: "openrouter", model: "gpt-5.5" }); + mockModelInfo.set("codex/gpt-5.5", { provider: "codex", model: "gpt-5.5" }); + mockCombos.clear(); + + const result = await resolveResponsesApiModel("gpt-5.5", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "codex/gpt-5.5", "bare codex model must be rewritten"); + assert.equal(result.changed, true, "bare codex model must be marked as changed"); +}); + +test("already-prefixed codex/gpt-5.5 passes through unchanged", async () => { + mockModelInfo.clear(); + mockModelInfo.set("codex/gpt-5.5", { provider: "codex", model: "gpt-5.5" }); + mockCombos.clear(); + + const result = await resolveResponsesApiModel("codex/gpt-5.5", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "codex/gpt-5.5", "already-prefixed model must pass through"); + assert.equal(result.changed, false, "already-prefixed model must not be marked as changed"); +}); + +test("combo name with combo/ prefix is NOT rewritten", async () => { + mockModelInfo.clear(); + mockCombos.clear(); + mockCombos.add("combo/my-combo"); + + const result = await resolveResponsesApiModel("combo/my-combo", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "combo/my-combo", "combo/ prefix must pass through unchanged"); + assert.equal(result.changed, false, "combo/ prefix must not be marked as changed"); +}); + +test("bare model that is not a combo and has no codex mapping passes through", async () => { + mockModelInfo.clear(); + mockModelInfo.set("some-random-model", { provider: "openrouter", model: "some-random-model" }); + mockModelInfo.set("codex/some-random-model", { provider: null, model: "some-random-model" }); + mockCombos.clear(); + + const result = await resolveResponsesApiModel("some-random-model", mockGetModelInfo, mockIsCombo); + assert.equal(result.model, "some-random-model", "unmapped bare model must pass through"); + assert.equal(result.changed, false, "unmapped bare model must not be marked as changed"); +});