diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index fcf3132746..737bfcf8f9 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -74,6 +74,7 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set(["gpt-5.5"]); +export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]); /** * Resolve provider alias to provider ID @@ -279,6 +280,14 @@ function resolveModelByProviderInference(modelId, extendedContext) { const nonOpenAIProviders = providers.filter((p) => p !== "openai"); + if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) { + return { + provider: "codex", + model: modelId, + extendedContext, + }; + } + if (providers.includes("codex") && CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId)) { return { provider: "codex", diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 12fe5b302c..f98332c102 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -16,6 +16,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model.ts"; import { getAllSyncedAvailableModels } from "@/lib/db/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; @@ -377,6 +378,33 @@ export async function getUnifiedModelsResponse( } } + for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) { + if (!providerSupportsModel("codex", modelId)) continue; + if (getModelIsHidden("codex", modelId)) continue; + + const alias = providerIdToAlias.codex || "cx"; + const aliasId = `${alias}/${modelId}`; + const providerIdModel = `codex/${modelId}`; + const entries = [ + { id: aliasId, parent: null }, + { id: providerIdModel, parent: aliasId }, + { id: modelId, parent: providerIdModel }, + ]; + + for (const entry of entries) { + if (models.some((existingModel) => existingModel.id === entry.id)) continue; + models.push({ + id: entry.id, + object: "model", + created: timestamp, + owned_by: "codex", + permission: [], + root: modelId, + parent: entry.parent, + }); + } + } + try { const syncedModelsByProvider = await getAllSyncedAvailableModels(); for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b8fcc35448..7ff8e16167 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -485,7 +485,12 @@ async function handleSingleModelChat( isCombo: boolean = false ) { // 1. Resolve model → provider/model - const resolved = await resolveModelOrError(modelStr, body, clientRawRequest?.endpoint); + const resolved = await resolveModelOrError( + modelStr, + body, + clientRawRequest?.endpoint, + clientRawRequest?.headers + ); if (resolved.error) return resolved.error; const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved; diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 3079acce78..95fdfb73de 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -43,8 +43,59 @@ const PREFERRED_BY_FAMILY: Record = { mimo: "moonshot", }; -export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") { +const CODEX_NATIVE_RESPONSES_MODELS = new Set(["gpt-5.5"]); + +function getHeaderValue(headers: Record | null | undefined, name: string) { + if (!headers || typeof headers !== "object") return ""; + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lowerName) continue; + return Array.isArray(value) ? value.join(",") : String(value ?? ""); + } + return ""; +} + +function isCodexNativeResponsesRequest( + body: any, + endpointPath: string, + headers: Record | null | undefined +) { + const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, ""); + if (!/(^|\/)responses(?=\/|$)/i.test(normalizedEndpoint)) return false; + if (/\/responses\/compact$/i.test(normalizedEndpoint)) return true; + + const userAgent = getHeaderValue(headers, "user-agent").toLowerCase(); + if (userAgent.includes("codex")) return true; + if (getHeaderValue(headers, "x-codex-session-id")) return true; + if (getHeaderValue(headers, "x-codex-window-id")) return true; + if (getHeaderValue(headers, "x-codex-turn-metadata")) return true; + + const metadataSource = + body && typeof body === "object" && body.metadata && typeof body.metadata === "object" + ? String(body.metadata.source || "") + : ""; + return metadataSource.toLowerCase().includes("codex"); +} + +export async function resolveModelOrError( + modelStr: string, + body: any, + endpointPath: string = "", + requestHeaders: Record | null | undefined = null +) { const modelInfo = await getModelInfo(modelStr); + const sourceFormat = detectFormatFromEndpoint(body, endpointPath); + + if ( + modelInfo.provider === "openai" && + typeof modelInfo.model === "string" && + CODEX_NATIVE_RESPONSES_MODELS.has(modelInfo.model) && + sourceFormat === "openai-responses" && + isCodexNativeResponsesRequest(body, endpointPath, requestHeaders) + ) { + log.info("ROUTING", `${modelStr} → codex/${modelInfo.model} (Codex native responses)`); + modelInfo.provider = "codex"; + } // Forced-rewrite: codex provider doesn't serve DeepSeek/Qwen/Kimi/etc. Reroute // these to their canonical native provider so the request lands on the right @@ -113,7 +164,6 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP } const { provider, model, extendedContext } = modelInfo; - const sourceFormat = detectFormatFromEndpoint(body, endpointPath); const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider; let targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider); if ((modelInfo as any).apiFormat === "responses") { diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 168ed13ca5..e0b2668423 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -89,6 +89,30 @@ test("resolveModelOrError rejects malformed model strings", async () => { assert.match(json.error.message, /Invalid model format/i); }); +test("resolveModelOrError routes Codex native compact gpt-5.5 requests to Codex", async () => { + const result = await resolveModelOrError( + "gpt-5.5", + { model: "gpt-5.5", input: "compact this session", reasoning: { effort: "xhigh" } }, + "/v1/responses/compact", + { "user-agent": "codex-cli/0.128.0" } + ); + + assert.equal(result.provider, "codex"); + assert.equal(result.model, "gpt-5.5"); +}); + +test("resolveModelOrError keeps non-Codex gpt-5.5 Responses requests on OpenAI", async () => { + const result = await resolveModelOrError( + "gpt-5.5", + { model: "gpt-5.5", input: "hello" }, + "/v1/responses", + { "user-agent": "OpenAI/Node" } + ); + + assert.equal(result.provider, "openai"); + assert.equal(result.model, "gpt-5.5"); +}); + test("checkPipelineGates blocks providers with an open circuit breaker", async () => { const breaker = getCircuitBreaker("openai"); breaker.state = STATE.OPEN; diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 1ca0db09c8..5dbb9393f4 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -288,6 +288,36 @@ test("v1 models catalog exposes refreshed GitHub Copilot aliases and drops retir ); }); +test("v1 models catalog exposes bare Codex-preferred IDs for native Codex clients", async () => { + await seedConnection("codex", { + authType: "oauth", + name: "codex-native", + apiKey: null, + accessToken: "codex-access", + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const getModel = (id: string) => body.data.find((item) => item.id === id); + + assert.equal(response.status, 200); + const modelId = "codex-auto-review"; + const bareModel = getModel(modelId); + const providerModel = getModel(`codex/${modelId}`); + const aliasModel = getModel(`cx/${modelId}`); + const openAiModel = getModel(`openai/${modelId}`); + + assert.ok(bareModel, `expected bare ${modelId} model`); + assert.ok(providerModel, `expected codex/${modelId} model`); + assert.ok(aliasModel, `expected cx/${modelId} model`); + assert.equal(openAiModel, undefined); + assert.equal(bareModel.owned_by, "codex"); + assert.equal(bareModel.parent, providerModel.id); + assert.equal(providerModel.parent, aliasModel.id); +}); + test("v1 models catalog exposes Antigravity client-visible preview aliases instead of upstream internal IDs", async () => { await seedConnection("antigravity", { authType: "oauth", diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 3f9749651a..16e0a683da 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -28,9 +28,9 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => { assert.equal(info.model, "gpt-4o"); }); -test("getModelInfoCore routes removed codex-auto-review through the default fallback", async () => { +test("getModelInfoCore routes native codex-auto-review to codex", async () => { const info = await getModelInfoCore("codex-auto-review", {}); - assert.equal(info.provider, "openai"); + assert.equal(info.provider, "codex"); assert.equal(info.model, "codex-auto-review"); });