diff --git a/src/app/api/internal/codex-responses-ws/modelResolution.ts b/src/app/api/internal/codex-responses-ws/modelResolution.ts index 244e63d22d..3011c8adbf 100644 --- a/src/app/api/internal/codex-responses-ws/modelResolution.ts +++ b/src/app/api/internal/codex-responses-ws/modelResolution.ts @@ -46,3 +46,38 @@ export async function resolveCodexWsModelInfo( const codexInfo = await resolve(`codex/${requestedModel}`); return codexInfo?.provider === "codex" ? codexInfo : info; } + +/** + * Resolve a model ID for the HTTP Responses path, applying codex preference + * for bare ChatGPT-style model IDs (those without a provider prefix). + * + * When the Codex CLI falls back from WebSocket to HTTP (#15492), it sends bare + * model IDs like "gpt-5.5" to /v1/responses. Without this resolution, OmniRoute + * routes them to openrouter/openai instead of the configured codex OAuth + * connections, producing "No credentials for provider: openrouter". + * + * @param requestedModel the model id from the Responses API request body + * @param resolve a getModelInfo-style resolver + * @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 +): Promise<{ model: string; changed: boolean }> { + if (!requestedModel || requestedModel.includes("/")) { + return { model: requestedModel, changed: false }; + } + + try { + const resolved = await resolveCodexWsModelInfo(requestedModel, resolve); + if (resolved?.provider !== "codex") { + return { model: requestedModel, changed: false }; + } + + const prefixed = `codex/${resolved.model || requestedModel}`; + return { model: prefixed, changed: true }; + } catch { + return { model: requestedModel, changed: false }; + } +} diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index 0dbb556449..163471cace 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -1,5 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; +import { resolveResponsesApiModel } from "../internal/codex-responses-ws/modelResolution"; +import { getModelInfo } 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 @@ -19,6 +21,36 @@ export async function OPTIONS() { }); } +/** + * Rewrite a bare ChatGPT-style model id to the codex/ prefix when the model + * resolves to a codex provider. This fixes the Codex CLI WS→HTTP fallback path: + * the CLI sends bare "gpt-5.5" over HTTP after WS closes (1008 Policy), and + * without this rewrite OmniRoute routes it to openrouter instead of codex. + * + * 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 { + try { + const clone = request.clone(); + const body = await clone.json().catch(() => null); + if (!body || typeof body !== "object" || typeof body.model !== "string") { + return request; + } + const { model, changed } = await resolveResponsesApiModel(body.model, getModelInfo); + if (!changed) return request; + + return new Request(request.url, { + method: request.method, + headers: request.headers, + body: JSON.stringify({ ...body, model }), + signal: request.signal, + }); + } catch { + return request; + } +} + /** * POST /v1/responses - OpenAI Responses API format * Handled by the unified chat handler (openai-responses format auto-detected). @@ -28,9 +60,10 @@ export async function POST(request) { // client drops the connection if no bytes arrive within ~5s. Keep the connection // warm with early keepalives while the upstream produces its first token (#2544). // Non-streaming callers (JSON) keep the original verbatim path untouched. + const resolved = await withCodexPreferredModel(request); const accept = String(request.headers?.get?.("accept") || "").toLowerCase(); if (accept.includes("text/event-stream")) { - return await withEarlyStreamKeepalive(handleChat(request), { signal: request.signal }); + return await withEarlyStreamKeepalive(handleChat(resolved), { signal: request.signal }); } - return await handleChat(request); + return await handleChat(resolved); } diff --git a/tests/unit/codex-ws-http-fallback.test.ts b/tests/unit/codex-ws-http-fallback.test.ts new file mode 100644 index 0000000000..5151dc0928 --- /dev/null +++ b/tests/unit/codex-ws-http-fallback.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for resolveResponsesApiModel — ensures bare ChatGPT model IDs are + * codex-preferred when the Codex CLI falls back from WebSocket to HTTP and + * hits /v1/responses with a bare model id (e.g. "gpt-5.5"). + * + * Root cause: WS transport requires bare ids (codex/ prefix rejected client-side), + * but HTTP routing resolves bare "gpt-5.5" → openrouter, not codex. + * Fix: /v1/responses pre-resolves bare ids with codex preference. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveResponsesApiModel } from "../../src/app/api/internal/codex-responses-ws/modelResolution.ts"; +import type { ModelResolver } from "../../src/app/api/internal/codex-responses-ws/modelResolution.ts"; + +/** Resolver that maps model id to a { provider, model } pair. */ +function makeResolver(map: Record): ModelResolver { + return async (id: string) => map[id] ?? {}; +} + +test("bare gpt-5.5 that resolves to codex is rewritten to codex/gpt-5.5", async () => { + const resolve = makeResolver({ + "gpt-5.5": { provider: "openrouter", model: "gpt-5.5" }, + "codex/gpt-5.5": { provider: "codex", model: "gpt-5.5" }, + }); + const result = await resolveResponsesApiModel("gpt-5.5", resolve); + assert.equal(result.model, "codex/gpt-5.5"); + assert.equal(result.changed, true); +}); + +test("bare gpt-4o that has no codex mapping is passed through unchanged", async () => { + const resolve = makeResolver({ + "gpt-4o": { provider: "openai", model: "gpt-4o" }, + // codex/gpt-4o is NOT in the registry + }); + const result = await resolveResponsesApiModel("gpt-4o", resolve); + assert.equal(result.model, "gpt-4o"); + assert.equal(result.changed, false); +}); + +test("already-prefixed codex/gpt-5.5 is passed through unchanged", async () => { + const resolve = makeResolver({ + "codex/gpt-5.5": { provider: "codex", model: "gpt-5.5" }, + }); + const result = await resolveResponsesApiModel("codex/gpt-5.5", resolve); + assert.equal(result.model, "codex/gpt-5.5"); + assert.equal(result.changed, false); +}); + +test("bare model that resolves to openrouter AND has no codex equivalent is passed through", async () => { + const resolve = makeResolver({ + "llama-3.1": { provider: "openrouter", model: "llama-3.1" }, + "codex/llama-3.1": { provider: "openrouter", model: "llama-3.1" }, // no codex match + }); + const result = await resolveResponsesApiModel("llama-3.1", resolve); + assert.equal(result.model, "llama-3.1"); + assert.equal(result.changed, false); +}); + +test("empty model string is passed through unchanged", async () => { + const resolve = makeResolver({}); + const result = await resolveResponsesApiModel("", resolve); + assert.equal(result.model, ""); + assert.equal(result.changed, false); +}); + +test("bare gpt-5.5 that directly resolves to codex (without prefix retry) is rewritten", async () => { + const resolve = makeResolver({ + "gpt-5.5": { provider: "codex", model: "gpt-5.5" }, + }); + const result = await resolveResponsesApiModel("gpt-5.5", resolve); + assert.equal(result.model, "codex/gpt-5.5"); + assert.equal(result.changed, true); +}); + +test("other-provider/ prefix passes through unchanged (not a bare model)", async () => { + const resolve = makeResolver({ + "anthropic/claude-opus-4": { provider: "anthropic", model: "claude-opus-4" }, + }); + const result = await resolveResponsesApiModel("anthropic/claude-opus-4", resolve); + assert.equal(result.model, "anthropic/claude-opus-4"); + assert.equal(result.changed, false); +}); + +test("resolver throwing is handled gracefully — model passes through unchanged", async () => { + const resolve: ModelResolver = async () => { + throw new Error("resolver unavailable"); + }; + const result = await resolveResponsesApiModel("gpt-5.5", resolve); + assert.equal(result.model, "gpt-5.5"); + assert.equal(result.changed, false); +});