diff --git a/.env.example b/.env.example index e4ef62f091..f1a333125c 100644 --- a/.env.example +++ b/.env.example @@ -1830,7 +1830,7 @@ APP_LOG_TO_FILE=true # short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6 # AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working — # which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only -# the full provider-id prefix (and drops providers whose alias is already canonical). +# the full provider-id prefix (providers whose alias is already canonical keep their one id). # A client can override per request with GET /v1/models?prefix=alias instead. # Also configurable from Dashboard > Settings > Feature Flags. # Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts diff --git a/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md b/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md new file mode 100644 index 0000000000..34611fa2ef --- /dev/null +++ b/changelog.d/fixes/12381-models-catalog-canonical-self-aliased.md @@ -0,0 +1 @@ +- **fix(api):** `GET /v1/models` with `MODELS_CATALOG_PREFIX_MODE=canonical` (or `?prefix=canonical`) now lists providers whose registry alias is undefined or equal to their own id (Antigravity, Antigravity CLI and other self-aliased built-ins) — their single `provider/model` id was dropped by the alias/canonical duplicate guard in the static, synced, custom and alias-backed catalog loops ([#12058](https://github.com/diegosouzapw/OmniRoute/issues/12058)) — thanks @cheynetom diff --git a/docs/guides/VSCODE-COPILOT.md b/docs/guides/VSCODE-COPILOT.md index e0a4386fc0..4b49f34779 100644 --- a/docs/guides/VSCODE-COPILOT.md +++ b/docs/guides/VSCODE-COPILOT.md @@ -63,7 +63,7 @@ changing the server-wide setting for your other clients. On a reference instance If you would rather fix it server-wide for _every_ client, set the `MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See [API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the -query parameter and the warning about `canonical`. +query parameter and the per-mode table. ### It hides models that cannot chat diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 32d9291207..42dbb44632 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -405,11 +405,11 @@ GET /v1/models?prefix=dual # both forms (server default) GET /v1/models?prefix=canonical # only the full provider-id prefix ``` -| Mode | Emits | Notes | -| ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. | -| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. | -| `canonical` | `claude/claude-sonnet-4-6` | ⚠️ The canonical row is only emitted when the canonical provider id **differs** from the alias, so providers without a distinct alias emit nothing in this mode. Prefer `alias` for a de-duplicated list. | +| Mode | Emits | Notes | +| ----------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. | +| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. | +| `canonical` | `claude/claude-sonnet-4-6` | One entry per model under the full provider-id prefix. Providers without a distinct alias (e.g. `antigravity/…`, `agy/…`) emit their single id here too, so nothing is lost. | A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent` field pointing at the primary id. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c059b2b1ad..c63cadc12c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -949,7 +949,7 @@ Automatic model pricing data synchronization from external sources. | Variable | Default | Source File | Description | | ------------------------- | ------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Periodic Arena AI leaderboard ELO sync, configurable from Dashboard Feature Flags or with `false` to opt out. | -| `MODELS_CATALOG_PREFIX_MODE` | `dual` | `src/shared/constants/featureFlagDefinitions.ts`, `src/app/api/v1/models/catalog.ts` | Prefix form used for model ids in `GET /v1/models`. `dual` advertises both the short alias prefix and the canonical provider prefix for every model (backward compatibility — roughly doubles the catalog); `alias` emits one id per model; `canonical` emits only the full provider-id prefix and omits providers whose alias already is the canonical id. Clients can override per request with `?prefix=alias`. See [API_REFERENCE](API_REFERENCE.md#model-id-prefixes-prefix). | +| `MODELS_CATALOG_PREFIX_MODE` | `dual` | `src/shared/constants/featureFlagDefinitions.ts`, `src/app/api/v1/models/catalog.ts` | Prefix form used for model ids in `GET /v1/models`. `dual` advertises both the short alias prefix and the canonical provider prefix for every model (backward compatibility — roughly doubles the catalog); `alias` emits one id per model; `canonical` emits only the full provider-id prefix (providers whose alias already is the canonical id keep their single entry). Clients can override per request with `?prefix=alias`. See [API_REFERENCE](API_REFERENCE.md#model-id-prefixes-prefix). | | `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | `src/lib/arenaEloSync.ts` | Sync interval in seconds. | --- diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e5e427f54f..3e60e8bea2 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1089,7 +1089,14 @@ async function buildUnifiedModelsResponseCore( ); const thinkingCapabilities = Object.keys(thinkingFields).length > 0 ? { capabilities: thinkingFields } : {}; - if (includeAlias) { + // #12058: a self-aliased provider (registry `alias` undefined or equal to its + // own id — antigravity, agy, most built-ins) has a single id form, so its + // alias row IS its canonical row. Emit it in canonical mode too; the + // canonical branch below still skips it (`canonicalProviderId !== alias`), + // so dual mode cannot double up. Same class as #11832 (custom nodes, + // PR #11918), which only widened the synced/custom/alias-backed loops. + const selfAliased = canonicalProviderId === alias; + if (includeAlias || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1181,6 +1188,8 @@ async function buildUnifiedModelsResponseCore( const prefix = providerIdToPrefix[providerId]; const alias = prefix || providerIdToAlias[providerId] || providerId; const canonicalProviderId = resolveCanonicalProviderId(alias, providerId); + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; const parentProviderType = nodeIdToProviderType[providerId]; if ( @@ -1280,7 +1289,7 @@ async function buildUnifiedModelsResponseCore( continue; } - if (includeAlias || Boolean(prefix)) { + if (includeAlias || Boolean(prefix) || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1628,6 +1637,8 @@ async function buildUnifiedModelsResponseCore( const prefix = providerIdToPrefix[providerId]; const alias = prefix || providerIdToAlias[providerId] || providerId; const canonicalProviderId = resolveCanonicalProviderId(alias, providerId); + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; // Only include if provider is active — check alias, canonical ID, raw providerId, // or the parent provider type (for compatible providers whose node ID is a UUID) @@ -1733,7 +1744,7 @@ async function buildUnifiedModelsResponseCore( ? getCustomVisionCapabilityFields(model, aliasId, modelId) : null; - if (includeAlias || Boolean(prefix)) { + if (includeAlias || Boolean(prefix) || selfAliased) { models.push({ id: aliasId, object: "model", @@ -1852,7 +1863,9 @@ async function buildUnifiedModelsResponseCore( const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId); - if (includeAlias || Boolean(nodePrefix)) { + // #12058: see the static loop — the alias row is the only row here. + const selfAliased = canonicalProviderId === alias; + if (includeAlias || Boolean(nodePrefix) || selfAliased) { models.push({ id: aliasId, object: "model", diff --git a/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts new file mode 100644 index 0000000000..d029146c0b --- /dev/null +++ b/tests/unit/12058-models-catalog-canonical-self-aliased.test.ts @@ -0,0 +1,224 @@ +/** + * Regression test for #12058 — `MODELS_CATALOG_PREFIX_MODE=canonical` (or + * `?prefix=canonical`) dropped every chat row of a *self-aliased* provider: a + * registry entry whose `alias` is undefined (`antigravity`) or equal to its own id + * (`agy`, and most built-in providers). + * + * Root cause: every emission loop in `catalog.ts` pushes the `alias/model` row only + * when `includeAlias` is set and the `canonicalProviderId/model` row only when + * `canonicalProviderId !== alias` (a dual-mode duplicate guard). For a self-aliased + * provider both ids are the same string, so in canonical mode neither branch fires + * and the provider vanishes. #11832 / PR #11918 fixed the same class for custom + * provider nodes (`includeAlias || Boolean(prefix)`) but left built-in providers + * behind. + * + * Fix: treat the alias row as the canonical row whenever the two ids coincide, in + * the static, synced, custom and alias-backed loops alike. `alias` and `dual` modes + * already emitted that single row, so their output must not change. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-12058-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const aliasesDb = await import("../../src/lib/db/models/aliases.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +type CatalogRow = { id: string; parent: string | null; root: string | null }; +type PrefixMode = "alias" | "canonical" | "dual"; + +// Both ship this id in their curated static catalog (ANTIGRAVITY_PUBLIC_MODELS / +// AGY_PUBLIC_MODELS). `antigravity` has `alias: undefined`, `agy` has `alias: "agy"`. +const SELF_ALIASED_PROVIDERS = ["antigravity", "agy"] as const; +const STATIC_MODEL_ID = "gemini-3.7-flash-high"; + +// A self-aliased api-key provider used to exercise the synced / custom / +// alias-backed loops, which carry the same guard as the static loop. +const SYNCED_PROVIDER = "groq"; +const SYNCED_MODEL_ID = "probe-synced-12058"; +// A synced audio model must survive too (it is a chat-loop row with `type: "audio"`). +const SYNCED_AUDIO_MODEL_ID = "probe-tts-12058"; +const CUSTOM_MODEL_ID = "probe-custom-12058"; +const ALIAS_BACKED_MODEL_ID = "probe-alias-backed-12058"; + +// Control: a normally-aliased provider (alias `cc`, canonical `claude`) whose +// mode gating must stay exactly as it was. +const CONTROL_ALIAS_ID = "cc/claude-sonnet-4-6"; +const CONTROL_CANONICAL_ID = "claude/claude-sonnet-4-6"; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedOauthConnection(provider: string) { + await providersDb.createProviderConnection({ + provider, + authType: "oauth", + name: `${provider}-12058`, + apiKey: null, + accessToken: `${provider}-access-token`, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +async function seedCatalog() { + for (const provider of SELF_ALIASED_PROVIDERS) await seedOauthConnection(provider); + await seedOauthConnection("claude"); + + const connection = await providersDb.createProviderConnection({ + provider: SYNCED_PROVIDER, + authType: "apikey", + name: `${SYNCED_PROVIDER}-12058`, + apiKey: "sk-test-12058", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await modelsDb.replaceSyncedAvailableModelsForConnection( + SYNCED_PROVIDER, + (connection as { id: string }).id, + [ + { id: SYNCED_MODEL_ID, source: "imported", supportedEndpoints: ["chat"] }, + { id: SYNCED_AUDIO_MODEL_ID, source: "imported", supportedEndpoints: ["audio-speech"] }, + ] + ); + await modelsDb.addCustomModel(SYNCED_PROVIDER, CUSTOM_MODEL_ID, "Probe Custom 12058"); + await aliasesDb.setModelAlias( + ALIAS_BACKED_MODEL_ID, + `${SYNCED_PROVIDER}/${ALIAS_BACKED_MODEL_ID}` + ); +} + +async function getRows(mode: PrefixMode): Promise { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request(`http://localhost/api/v1/models?prefix=${mode}`) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: CatalogRow[] }; + return body.data; +} + +function idsWithPrefix(rows: CatalogRow[], prefix: string): string[] { + return rows.map((row) => row.id).filter((id) => id.startsWith(`${prefix}/`)); +} + +function duplicates(rows: CatalogRow[]): string[] { + const ids = rows.map((row) => row.id); + return ids.filter((id, index) => ids.indexOf(id) !== index); +} + +function assertExactlyOnce(rows: CatalogRow[], id: string, mode: PrefixMode) { + const matches = rows.filter((row) => row.id === id); + assert.equal( + matches.length, + 1, + `${mode} mode: expected exactly one "${id}", got ${matches.length}` + ); + return matches[0]; +} + +test.beforeEach(async () => { + await resetStorage(); + await seedCatalog(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("#12058 canonical mode lists the curated models of self-aliased providers once, re-rooted", async () => { + const rows = await getRows("canonical"); + + for (const provider of SELF_ALIASED_PROVIDERS) { + const row = assertExactlyOnce(rows, `${provider}/${STATIC_MODEL_ID}`, "canonical"); + // The single surviving row is the head of its chain: no parent to point at. + assert.equal(row.parent, null, `${provider}: the canonical row must not carry a parent`); + assert.equal(row.root, STATIC_MODEL_ID, `${provider}: root must be the bare model id`); + + // Anti-vacuity: the whole curated chat catalog is back, not just the sampled id. + const listed = idsWithPrefix(rows, provider); + assert.ok( + listed.length >= 5, + `${provider}: expected the curated catalog in canonical mode, got ${JSON.stringify(listed)}` + ); + } + + assert.deepEqual(duplicates(rows), [], "canonical mode must not emit duplicate ids"); +}); + +test("#12058 canonical mode keeps synced, custom and alias-backed rows of a self-aliased provider", async () => { + const rows = await getRows("canonical"); + + for (const modelId of [ + SYNCED_MODEL_ID, + SYNCED_AUDIO_MODEL_ID, + CUSTOM_MODEL_ID, + ALIAS_BACKED_MODEL_ID, + ]) { + const row = assertExactlyOnce(rows, `${SYNCED_PROVIDER}/${modelId}`, "canonical"); + assert.equal(row.parent, null, `${modelId}: the canonical row must not carry a parent`); + } +}); + +test("#12058 canonical mode still suppresses the alias row of a normally-aliased provider", async () => { + // Guards against "fixing" the defect by disabling the alias gate outright. + const rows = await getRows("canonical"); + const ids = new Set(rows.map((row) => row.id)); + + assert.ok(ids.has(CONTROL_CANONICAL_ID), `expected "${CONTROL_CANONICAL_ID}" in canonical mode`); + assert.equal( + ids.has(CONTROL_ALIAS_ID), + false, + `"${CONTROL_ALIAS_ID}" must stay suppressed in canonical mode` + ); +}); + +test("#12058 self-aliased providers emit the same single id set in every mode; alias/dual stay unchanged", async () => { + const byMode = { + alias: await getRows("alias"), + canonical: await getRows("canonical"), + dual: await getRows("dual"), + } satisfies Record; + + for (const mode of ["alias", "dual"] as const) { + assert.deepEqual(duplicates(byMode[mode]), [], `${mode} mode must not emit duplicate ids`); + } + + // A self-aliased provider has exactly one id form, so all three modes must agree. + for (const provider of [...SELF_ALIASED_PROVIDERS, SYNCED_PROVIDER]) { + const aliasIds = idsWithPrefix(byMode.alias, provider).sort(); + assert.ok(aliasIds.length > 0, `${provider}: alias mode must list the provider at all`); + assert.deepEqual( + idsWithPrefix(byMode.canonical, provider).sort(), + aliasIds, + `${provider}: canonical mode must list the same ids as alias mode` + ); + assert.deepEqual( + idsWithPrefix(byMode.dual, provider).sort(), + aliasIds, + `${provider}: dual mode must list the same ids as alias mode` + ); + } + + // The normally-aliased control keeps its per-mode shape. + const aliasIds = new Set(byMode.alias.map((row) => row.id)); + const dualIds = new Set(byMode.dual.map((row) => row.id)); + assert.ok(aliasIds.has(CONTROL_ALIAS_ID), "alias mode keeps the cc/ row"); + assert.equal(aliasIds.has(CONTROL_CANONICAL_ID), false, "alias mode suppresses the claude/ row"); + assert.ok(dualIds.has(CONTROL_ALIAS_ID), "dual mode keeps the cc/ row"); + assert.ok(dualIds.has(CONTROL_CANONICAL_ID), "dual mode keeps the claude/ row"); +});