Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/app/api/v1/responses/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,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 { getModelInfo, getComboForModel } from "@/sse/services/model";

// NOTE: We do NOT call initTranslators() here — the translator registry is
// bootstrapped at module level inside open-sse/translator/index.ts when it
Expand Down Expand Up @@ -30,13 +30,27 @@ 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<Request> {
export async function withCodexPreferredModel(request: Request): Promise<Request> {
try {
const clone = request.clone();
const body = await clone.json().catch(() => null);
if (!body || typeof body !== "object" || typeof body.model !== "string") {
return request;
}

// FIX #3233: Do NOT rewrite combo names to codex/ prefix.
// Combo names like "paid-premium" or "n8n-text" have no "/" and would
// otherwise be rewritten to "codex/paid-premium", breaking combo
// resolution and causing "No credentials for provider: codex".
// Performance: only check combos for bare model ids (no "/"); already

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Performance issue — database lookup on every request even for models that cannot be combo names

getComboForModel queries the database (via getComboByName and potentially resolveComboForModel), but this check runs on every request including models already containing "/" (e.g., codex/gpt-5.5, openai/gpt-4o). These models can never be combo names since combo names don't have provider prefixes.

Suggestion: Add a guard before the DB call to skip combo lookup for models that already contain "/":

if (body.model.includes("/")) {
  return request;
}
const combo = await getComboForModel(body.model);

This matches the pattern used in resolveResponsesApiModel (line 68) and avoids unnecessary DB queries on the hot path.

// prefixed or combo/ names bypass the DB lookup entirely.
if (!body.model.includes("/")) {
const combo = await getComboForModel(body.model);
if (combo) {
return request;
}
}

const { model, changed } = await resolveResponsesApiModel(body.model, getModelInfo);
if (!changed) return request;

Expand Down
106 changes: 106 additions & 0 deletions tests/unit/combo-name-codex-responses-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Tests for withCodexPreferredModel 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 use node:test mock.module (experimental) to intercept the real
* production code. Run with:
* node --import tsx/esm --experimental-test-module-mocks --test tests/unit/combo-name-codex-responses-rewrite.test.ts
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mock } from "node:test";

const mockCombos = new Map<string, { name: string; models: string[] } | null>();
const mockModelInfo = new Map<string, { provider: string; model: string }>();

// Mock the model service module BEFORE importing the route handler.
// The route imports getComboForModel and getModelInfo at module load time,
// so the mock must be registered first.
mock.module("../../src/sse/services/model.ts", {
namedExports: {
getComboForModel: async (modelStr: string) => mockCombos.get(modelStr) ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Testing anti-pattern — re-implementing production logic instead of testing actual code

The test defines resolveWithCodexPreferredModel which re-implements the logic from withCodexPreferredModel. This defeats the purpose of testing — if the production code changes, these tests will continue to pass because they test a copy of the old logic, not the actual implementation.

Suggestion: Either:

  1. Remove this test file and add inline tests to src/app/api/v1/responses/route.ts using mocked dependencies, or
  2. Rewrite tests to import and spy on the actual resolveResponsesApiModel and getComboForModel functions, testing the real code path

getModelInfo: async (modelStr: string) => mockModelInfo.get(modelStr) ?? { provider: null, model: modelStr },
},
});

// Now import the route handler — it will pick up the mocked module.
const { withCodexPreferredModel } = await import(
"../../src/app/api/v1/responses/route.ts"
);

function makeRequest(model: string): Request {
return new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
}

test("combo name 'paid-premium' is NOT rewritten to codex/ prefix", async () => {
mockCombos.set("paid-premium", { name: "paid-premium", models: ["codex/gpt-5.4"] });
mockModelInfo.set("paid-premium", { provider: null, model: "paid-premium" });
mockModelInfo.set("codex/paid-premium", { provider: "codex", model: "paid-premium" });

const req = makeRequest("paid-premium");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "paid-premium", "combo name must pass through unchanged");
});

test("combo name 'n8n-text' is NOT rewritten to codex/ prefix", async () => {
mockCombos.set("n8n-text", { name: "n8n-text", models: ["cloudflare-ai/@cf/moonshotai/kimi-k2.6"] });
mockModelInfo.set("n8n-text", { provider: null, model: "n8n-text" });
mockModelInfo.set("codex/n8n-text", { provider: "codex", model: "n8n-text" });

const req = makeRequest("n8n-text");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "n8n-text", "combo name must pass through unchanged");
});

test("bare gpt-5.5 without combo is still rewritten to codex/gpt-5.5", async () => {
mockCombos.clear();
mockModelInfo.set("gpt-5.5", { provider: "openrouter", model: "gpt-5.5" });
mockModelInfo.set("codex/gpt-5.5", { provider: "codex", model: "gpt-5.5" });

const req = makeRequest("gpt-5.5");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "codex/gpt-5.5", "bare codex model must be rewritten");
});

test("already-prefixed codex/gpt-5.5 passes through unchanged", async () => {
mockCombos.clear();
mockModelInfo.set("codex/gpt-5.5", { provider: "codex", model: "gpt-5.5" });

const req = makeRequest("codex/gpt-5.5");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "codex/gpt-5.5", "already-prefixed model must pass through");
});

test("combo name with combo/ prefix is NOT rewritten", async () => {
mockCombos.set("combo/my-combo", { name: "my-combo", models: ["openai/gpt-4o"] });
mockModelInfo.clear();

const req = makeRequest("combo/my-combo");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "combo/my-combo", "combo/ prefix must pass through unchanged");
});

test("bare model that is not a combo and has no codex mapping passes through", async () => {
mockCombos.clear();
mockModelInfo.set("some-random-model", { provider: "openrouter", model: "some-random-model" });
mockModelInfo.set("codex/some-random-model", { provider: null, model: "some-random-model" });

const req = makeRequest("some-random-model");
const result = await withCodexPreferredModel(req);
const body = await result.json();
assert.equal(body.model, "some-random-model", "unmapped bare model must pass through");
});