mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(responses): resolve bare ChatGPT model ids to codex on HTTP fallback path (#3113)
When the Codex CLI falls back from WebSocket to HTTP (after 1008 Policy Violation or reconnect exhaustion), it POSTs to /v1/responses with the bare model id it was configured with (e.g. "gpt-5.5") — never the provider-prefixed form. OmniRoute's normal routing resolved that bare id to openrouter, not codex, producing: "No credentials for provider: openrouter" Fix: add resolveResponsesApiModel() (extending modelResolution.ts) and call it in /v1/responses before delegating to handleChat. The function applies the same codex-preference logic as resolveCodexWsModelInfo: bare "gpt-5.5" → codex/gpt-5.5 has a codex provider → rewrite ✅ bare "gpt-4o" → codex/gpt-4o not in registry → pass through ✅ "anthropic/x" → has "/" → skip resolution → pass through ✅ Errors are caught; original request is returned on any failure. 8 unit tests added (TDD — watched each fail before implementing). Refs: openai/codex#15492, openai/codex#13041, openai/codex#13039
This commit is contained in:
committed by
GitHub
parent
c9620eb741
commit
ed170229e7
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Request> {
|
||||
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);
|
||||
}
|
||||
|
||||
91
tests/unit/codex-ws-http-fallback.test.ts
Normal file
91
tests/unit/codex-ws-http-fallback.test.ts
Normal file
@@ -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<string, { provider: string; model: string }>): 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);
|
||||
});
|
||||
Reference in New Issue
Block a user