mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
224
tests/unit/12058-models-catalog-canonical-self-aliased.test.ts
Normal file
224
tests/unit/12058-models-catalog-canonical-self-aliased.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user