diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index a742155147..e7699be4b7 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -121,23 +121,6 @@ for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { } } const KNOWN_MODEL_IDS = new Set(MODEL_TO_PROVIDERS.keys()); -// #2877(B): include the effort-suffixed variants so a bare `gpt-5.5-xhigh` -// (and -high/-medium/-low) infers the codex provider instead of falling through -// the `/^gpt-/` → openai fallback (which 500s for codex-only credentials). -const CODEX_PREFERRED_UNPREFIXED_MODELS = new Set([ - "gpt-5.5", - "gpt-5.5-xhigh", - "gpt-5.5-high", - "gpt-5.5-medium", - "gpt-5.5-low", -]); -// Intentionally empty: an unprefixed codex-preferred model keeps its BARE id when -// inferred to codex. #2877 established that baking a `-medium` effort suffix silently -// overrides a client `reasoning.effort` (the Codex executor reads the suffix as an -// explicit modelEffort). This map was dormant while bare `gpt-5.5` hit the OpenAI -// short-circuit; #5887 makes the codex block reachable for bare `gpt-5.5`, so the -// `gpt-5.5 → gpt-5.5-medium` entry is removed to preserve #2877's bare-id contract. -const CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES = new Map([]); export const CODEX_NATIVE_UNPREFIXED_MODELS = new Set(["codex-auto-review"]); interface ProviderConnectionLike { @@ -261,35 +244,12 @@ function hasKnownProviderModel(providerOrAlias: string | null | undefined, model return true; } -function hasCodexPreferredUnprefixedModel(modelId: string) { - const canonicalModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId); - if (!canonicalModel) return false; - - const providerAlias = PROVIDER_ID_TO_ALIAS.codex || "codex"; - const models = PROVIDER_MODELS[providerAlias] || PROVIDER_MODELS.codex || []; - return models.some((entry) => entry?.id === canonicalModel); -} - function resolveInferredProviderModel(provider: string, modelId: string) { - const codexPreferredModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId); - if (provider === "codex" && codexPreferredModel) { - return codexPreferredModel; - } return resolveProviderModelAlias(provider, modelId); } -function getInferredProvidersForModel(modelId: string) { - const providers = [...(MODEL_TO_PROVIDERS.get(modelId) || [])]; - - if ( - CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId) && - hasCodexPreferredUnprefixedModel(modelId) && - !providers.includes("codex") - ) { - providers.push("codex"); - } - - return providers; +function getInferredProvidersForModel(modelId: string, dynamicProviders: string[] = []) { + return Array.from(new Set([...(MODEL_TO_PROVIDERS.get(modelId) || []), ...dynamicProviders])); } function isProviderConnectionActive(connection: ProviderConnectionLike) { @@ -323,6 +283,18 @@ async function getActiveProviderSet() { } } +async function getActiveSyncedProvidersForModel(modelId: string) { + try { + const { getActiveProvidersWithSyncedModel } = await import("@/lib/localDb"); + const providers = await getActiveProvidersWithSyncedModel(modelId); + return providers + .map(resolveProviderAlias) + .filter((provider): provider is string => typeof provider === "string"); + } catch { + return []; + } +} + function isTruthyEnv(value: string | undefined) { return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); } @@ -521,10 +493,6 @@ function parseAliasTarget(target: string): ResolvedModelTarget | null { } async function resolveModelByProviderInference(modelId: string, extendedContext: boolean) { - const providers = getInferredProvidersForModel(modelId); - - const nonOpenAIProviders = providers.filter((p) => p !== "openai"); - if (CODEX_NATIVE_UNPREFIXED_MODELS.has(modelId)) { return { provider: "codex", @@ -533,21 +501,24 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } - const [activeProviders, preferClaudeCodeForUnprefixedClaudeModels] = await Promise.all([ - getActiveProviderSet(), - getPreferClaudeCodeForUnprefixedClaudeModels(), - ]); + const [activeProviders, activeSyncedProviders, preferClaudeCodeForUnprefixedClaudeModels] = + await Promise.all([ + getActiveProviderSet(), + getActiveSyncedProvidersForModel(modelId), + getPreferClaudeCodeForUnprefixedClaudeModels(), + ]); + const providers = getInferredProvidersForModel(modelId, activeSyncedProviders); + const nonOpenAIProviders = providers.filter((p) => p !== "openai"); - // Codex-only setups must keep auto-routing codex-preferred unprefixed models - // (e.g. `gpt-5.5`) to codex even after those ids were added to the OpenAI - // static catalog (#5887). This block is guarded by `!activeProviders.has("openai")`, - // so it must run BEFORE the OpenAI short-circuit below; users WITH an active - // OpenAI connection still fall through to the OpenAI default. + // Bare model IDs from Codex CLI do not preserve OmniRoute's `cx/` prefix. + // Route overlapping models through Codex only for Codex-only installations; + // when OpenAI is also active, preserve the historical OpenAI default below. + // Models advertised only by an active synced Codex catalog still reach the + // single-candidate path, covering future models without version-specific sets. if ( activeProviders?.has("codex") && !activeProviders.has("openai") && - providers.includes("codex") && - CODEX_PREFERRED_UNPREFIXED_MODELS.has(modelId) + providers.includes("codex") ) { return { provider: "codex", @@ -556,9 +527,9 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: }; } - // Preserve historical behavior: OpenAI stays default when model exists there. - // Connection availability must not make unprefixed OpenAI models resolve to a - // different provider; callers can still force Codex with an explicit prefix. + // Outside the Codex subscription preference above, preserve the historical + // OpenAI default whenever its catalog contains the bare model ID. Callers can + // always make either route authoritative with an explicit provider prefix. if (providers.includes("openai")) { return { provider: "openai", diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 2face5cc31..d03f6fed90 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -447,6 +447,39 @@ export async function getAllSyncedAvailableModels(): Promise< return result; } +/** + * Find active providers whose synchronized catalog contains an exact model ID. + * + * This keeps request-time inference aligned with the same connection-scoped + * syncedAvailableModels data used by /v1/models without loading every model list + * into application memory for each request. + */ +export async function getActiveProvidersWithSyncedModel(modelId: string): Promise { + if (!modelId) return []; + + const db = getDbInstance(); + const rows = db + .prepare( + `SELECT DISTINCT pc.provider AS provider + FROM provider_connections pc + JOIN key_value kv + ON kv.namespace = 'syncedAvailableModels' + AND kv.key = pc.provider || ':' || pc.id + JOIN json_each(CASE WHEN json_valid(kv.value) THEN kv.value ELSE '[]' END) synced_model + WHERE pc.is_active = 1 + AND COALESCE( + json_extract(synced_model.value, '$.id'), + json_extract(synced_model.value, '$.name'), + json_extract(synced_model.value, '$.model') + ) = ?` + ) + .all(modelId) as Array<{ provider?: unknown }>; + + return rows + .map((row) => row.provider) + .filter((provider): provider is string => typeof provider === "string" && provider.length > 0); +} + /** * Replace the model list for a specific connection. * Key format: ':' diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 44075cabe2..b4fa93521f 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -67,10 +67,10 @@ export { getModelIsHidden, setModelIsHidden, getHiddenModelsByProvider, - // Synced Available Models getSyncedAvailableModels, getAllSyncedAvailableModels, + getActiveProvidersWithSyncedModel, replaceSyncedAvailableModelsForConnection, deleteSyncedAvailableModelsForConnection, deleteSyncedAvailableModelsForProvider, diff --git a/tests/unit/codex-gpt55-routing-5887.test.ts b/tests/unit/codex-gpt55-routing-5887.test.ts index 7c410585db..359ce7fcab 100644 --- a/tests/unit/codex-gpt55-routing-5887.test.ts +++ b/tests/unit/codex-gpt55-routing-5887.test.ts @@ -10,9 +10,9 @@ * `gpt-5.5`. Result: a codex-only user (no OpenAI connection) had `gpt-5.5` * routed to `openai`, and Codex-only hosted image generation failed. * - * Fix: move the codex-preference block ahead of the OpenAI short-circuit. It is - * guarded by `!activeProviders.has("openai")`, so users WITH an active OpenAI - * connection keep OpenAI as the default (behavior preserved). + * Catalog-driven inference generalizes the original GPT-5.5-specific fix while + * preserving its compatibility boundary: Codex-only users route through Codex, + * but OpenAI remains the historical default when both providers are active. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -50,8 +50,8 @@ test("#5887(a) codex-only setup infers codex for unprefixed gpt-5.5", async () = assert.equal(info.model, "gpt-5.5", "codex inference keeps the bare gpt-5.5 id"); }); -// (b) OpenAI active → OpenAI stays the default for gpt-5.5 (behavior preserved). -test("#5887(b) active OpenAI connection keeps gpt-5.5 on openai", async () => { +// (b) Codex + OpenAI active → preserve the historical OpenAI default. +test("#5887(b) active Codex and OpenAI connections keep gpt-5.5 on OpenAI", async () => { const conn = await providersDb.createProviderConnection({ provider: "openai", authType: "apikey", @@ -60,7 +60,7 @@ test("#5887(b) active OpenAI connection keeps gpt-5.5 on openai", async () => { openaiConnectionId = (conn as { id?: number | string })?.id; const info = await getModelInfoCore("gpt-5.5", null); - assert.equal(info.provider, "openai", "OpenAI stays default when openai is active"); + assert.equal(info.provider, "openai", "OpenAI remains default when both providers are active"); assert.equal(info.model, "gpt-5.5"); }); diff --git a/tests/unit/codex-synced-bare-model-routing.test.ts b/tests/unit/codex-synced-bare-model-routing.test.ts new file mode 100644 index 0000000000..41ec2f5ea1 --- /dev/null +++ b/tests/unit/codex-synced-bare-model-routing.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-synced-routing-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { getModelInfoCore } = await import("../../open-sse/services/model.ts"); + +type TestProvider = "anthropic" | "codex" | "openai"; + +const GPT_56_CODEX_MODEL = "gpt-5.6-sol"; +const FUTURE_CODEX_MODEL = "codex-next-preview"; +const FUTURE_NON_GPT_MODEL = "orion-preview-2027"; + +async function seedConnection(provider: TestProvider, isActive = true) { + return providersDb.createProviderConnection({ + provider, + authType: provider === "codex" ? "oauth" : "apikey", + name: `${provider}-routing-test`, + email: provider === "codex" ? `${provider}@example.com` : undefined, + apiKey: provider !== "codex" ? `sk-${provider}-routing-test` : undefined, + isActive, + providerSpecificData: provider === "codex" ? { workspaceId: "ws-routing-test" } : undefined, + }); +} + +async function seedSyncedModel(provider: TestProvider, modelId: string, isActive = true) { + const connection = await seedConnection(provider, isActive); + assert.ok(connection?.id, `${provider} connection must be created`); + await modelsDb.replaceSyncedAvailableModelsForConnection(provider, String(connection.id), [ + { + id: modelId, + name: modelId, + apiFormat: "openai-responses", + supportedEndpoints: ["chat"], + }, + ]); + return connection; +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("bare GPT-5.6 model routes through Codex when it is the only active provider", async () => { + await seedSyncedModel("codex", GPT_56_CODEX_MODEL); + + const info = await getModelInfoCore(GPT_56_CODEX_MODEL, null); + + assert.equal(info.provider, "codex"); + assert.equal(info.model, GPT_56_CODEX_MODEL); +}); + +test("OpenAI remains the historical default when both providers advertise the bare model", async () => { + await seedSyncedModel("codex", GPT_56_CODEX_MODEL); + await seedSyncedModel("openai", GPT_56_CODEX_MODEL); + + const info = await getModelInfoCore(GPT_56_CODEX_MODEL, null); + + assert.equal(info.provider, "openai"); + assert.equal(info.model, GPT_56_CODEX_MODEL); +}); + +test("Codex is inferred when only its active catalog advertises the model", async () => { + await seedSyncedModel("codex", FUTURE_CODEX_MODEL); + await seedConnection("openai"); + + const info = await getModelInfoCore(FUTURE_CODEX_MODEL, null); + + assert.equal(info.provider, "codex"); + assert.equal(info.model, FUTURE_CODEX_MODEL); +}); + +test("non-GPT models use the same active synchronized-catalog inference", async () => { + await seedSyncedModel("anthropic", FUTURE_NON_GPT_MODEL); + + const info = await getModelInfoCore(FUTURE_NON_GPT_MODEL, null); + + assert.equal(info.provider, "anthropic"); + assert.equal(info.model, FUTURE_NON_GPT_MODEL); +}); + +test("OpenAI remains selected when it is the only active provider advertising the model", async () => { + await seedSyncedModel("openai", GPT_56_CODEX_MODEL); + + const info = await getModelInfoCore(GPT_56_CODEX_MODEL, null); + + assert.equal(info.provider, "openai"); + assert.equal(info.model, GPT_56_CODEX_MODEL); +}); + +test("inactive Codex synchronized models do not influence bare-model routing", async () => { + await seedSyncedModel("codex", GPT_56_CODEX_MODEL, false); + await seedSyncedModel("openai", GPT_56_CODEX_MODEL); + + const info = await getModelInfoCore(GPT_56_CODEX_MODEL, null); + + assert.equal(info.provider, "openai"); + assert.equal(info.model, GPT_56_CODEX_MODEL); +}); + +test("OpenAI remains the historical default for overlapping static models", async () => { + await seedConnection("codex"); + await seedConnection("openai"); + + const info = await getModelInfoCore("gpt-5.5", null); + + assert.equal(info.provider, "openai"); + assert.equal(info.model, "gpt-5.5"); +}); + +test("OpenAI remains selected for an overlapping static model when Codex is inactive", async () => { + await seedConnection("codex", false); + await seedConnection("openai"); + + const info = await getModelInfoCore("gpt-5.5", null); + + assert.equal(info.provider, "openai"); + assert.equal(info.model, "gpt-5.5"); +}); + +test("explicit Codex and OpenAI prefixes remain authoritative", async () => { + await seedSyncedModel("codex", GPT_56_CODEX_MODEL); + await seedSyncedModel("openai", GPT_56_CODEX_MODEL); + + const codexAlias = await getModelInfoCore(`cx/${GPT_56_CODEX_MODEL}`, null); + const codexCanonical = await getModelInfoCore(`codex/${GPT_56_CODEX_MODEL}`, null); + const openai = await getModelInfoCore(`openai/${GPT_56_CODEX_MODEL}`, null); + + assert.equal(codexAlias.provider, "codex"); + assert.equal(codexCanonical.provider, "codex"); + assert.equal(openai.provider, "openai"); +});