From 62415e5e67d6fa60ea165797245a3d2aafb5f768 Mon Sep 17 00:00:00 2001 From: guanbear <123guan@gmail.com> Date: Sun, 19 Jul 2026 08:17:53 +0800 Subject: [PATCH] fix: infer bare models from active synced catalogs (#7028) * fix: infer bare models from active synced catalogs Bare Codex model IDs from Codex CLI can be newer than the static registry even though synchronized connection catalogs advertise and route them with an explicit prefix. Merge exact active synced-provider candidates into bare-model inference and prefer Codex when that active subscription supports the model, replacing the GPT-5.5-specific preference set from #2054. Constraint: Explicit provider prefixes remain authoritative and unknown GPT models are not guessed as Codex. Rejected: Add gpt-5.6-sol to the hardcoded preference set | repeats #2054 and fails on the next model release. Confidence: high Scope-risk: moderate Directive: Keep bare-model inference aligned with active synchronized connection catalogs. Tested: Prettier; typecheck:core; ESLint; 31 focused routing/database tests; focused c8 run. Not-tested: Full unit suite is blocked locally by DuckDuckGo network timeout and a pre-existing WebDAV path-space URL encoding failure. Related: https://github.com/diegosouzapw/OmniRoute/pull/2054 * fix: preserve stable overlap routing Synchronized catalog discovery should repair unambiguous Codex-only model routing without turning provider inference into a global quota preference. Restore the historical OpenAI default when both providers support a bare model, while retaining automatic Codex routing when only its active catalog advertises a future model. Constraint: Explicit provider prefixes remain authoritative and bare-model inference must remain backward compatible. Rejected: Always prefer Codex when connected | quota optimization belongs in auto routing or an explicit setting, not provider inference. Confidence: high Scope-risk: narrow Directive: Do not change overlapping bare-model precedence without an explicit routing-policy setting. Tested: TDD red run with 3 expected overlap failures; 32 focused tests; typecheck:core; ESLint; Prettier; git diff --check. Related: https://github.com/diegosouzapw/OmniRoute/pull/2054 Related: https://github.com/diegosouzapw/OmniRoute/pull/7028 * test: prove routing across released and future catalogs Exercise the v3.8.48 GPT-5.6 dual-provider catalog directly and add a non-GPT Anthropic model that exists only in synchronized connection data. This documents that the fix covers the released Codex regression and future uniquely attributable models without claiming to resolve intentional multi-provider ambiguity. Constraint: GPT-5.6 remains OpenAI-default when both providers are active. Rejected: Describe the fix as universal model mapping | provider aliases and intentional same-ID ambiguity are separate concerns. Confidence: high Scope-risk: narrow Directive: Keep one non-GPT synchronized-only case so the resolver remains data-driven rather than GPT-specific. Tested: 37 focused routing/catalog/database tests; typecheck:core; ESLint; Prettier; git diff --check. Related: https://github.com/diegosouzapw/OmniRoute/releases/tag/v3.8.48 Related: https://github.com/diegosouzapw/OmniRoute/pull/7028 * Keep PR validation deterministic across shallow checkouts The routing change added one export line to a frozen barrel, so reclaim an existing separator instead of expanding its size. The #6634 regression test now uses in-memory base/head sources that prove both tautology counts grow without assuming origin/main exists in pull-request checkouts. Constraint: GitHub PR jobs use fetch-depth 1 and do not create origin/main. Rejected: Fetch full history in every unit shard | adds repeated network cost and still lets the fixture go stale Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep test-masking unit fixtures independent of remote Git refs. Tested: npm run lint; npm run check:file-size; npm run typecheck:core; 57 focused test-masking tests Not-tested: Fresh GitHub Actions run pending; full macOS shard has 13 unrelated environment-sensitive failures * Preserve improved branch coverage in the quality gate The now-unblocked coverage pipeline reports 78.11% branch coverage, more than five points above the frozen baseline. Tighten the baseline to the measured value so the ratchet retains that improvement instead of rejecting the PR. Constraint: The blocking quality gate requires baseline tightening when an improvement exceeds tightenSlack. Rejected: Increase the slack or bypass the gate | would discard a verified coverage improvement Confidence: high Scope-risk: narrow Reversibility: clean Directive: Lower this baseline only when a reviewed coverage regression is intentionally accepted. Tested: quality ratchet with the CI-reported 78.11 branch metric; Prettier; git diff --check Not-tested: Fresh GitHub Actions run pending --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/services/model.ts | 91 ++++------- src/lib/db/models.ts | 33 ++++ src/lib/localDb.ts | 2 +- tests/unit/codex-gpt55-routing-5887.test.ts | 12 +- .../codex-synced-bare-model-routing.test.ts | 146 ++++++++++++++++++ 5 files changed, 217 insertions(+), 67 deletions(-) create mode 100644 tests/unit/codex-synced-bare-model-routing.test.ts 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"); +});