fix(api): list self-aliased providers in canonical models catalog mode (#12381)

GET /v1/models with MODELS_CATALOG_PREFIX_MODE=canonical dropped every chat row of a provider whose registry alias is undefined (antigravity) or equal to its own id (agy, most built-ins). Each emission loop pushes alias/model only when includeAlias, and canonicalProviderId/model only when the ids differ — for a self-aliased provider both are the same string, so neither fired. #11918 fixed the class for custom nodes but not built-ins, and not the static loop. The alias row is now treated as the canonical row whenever the ids coincide, across the static, synced, custom and alias-backed loops; the canonical branch's !== alias guard is untouched, so dual and alias output cannot double up. Docs that described the omission as intended are corrected.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:15:24 +02:00
committed by GitHub
parent 0ec7504024
commit 990aeca1db
7 changed files with 250 additions and 12 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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. |
---

View File

@@ -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",

View File

@@ -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<CatalogRow[]> {
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<PrefixMode, CatalogRow[]>;
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");
});