diff --git a/changelog.d/fixes/8072-catalog-provider-grouped-ordering.md b/changelog.d/fixes/8072-catalog-provider-grouped-ordering.md new file mode 100644 index 0000000000..f1b919cd40 --- /dev/null +++ b/changelog.d/fixes/8072-catalog-provider-grouped-ordering.md @@ -0,0 +1 @@ +- **fix(models):** `/v1/models` now publishes one contiguous provider-grouped block per provider instead of interleaved fragments. The catalog is assembled by many independent push loops (auto-combos, named combos, static registry, codex-native, synced, OpenRouter, specialty, custom, alias-backed, connection-fallback), so one provider's models previously landed in several separated blocks. A single stable, provider-grouped sort is applied at serialization, keyed by `owned_by` (canonical owner identity) rather than the model-id prefix — so a single routable public prefix that differs from its owner (e.g. no-auth OpenCode publishing `oc/` while keeping `owned_by: "opencode"`) stays contiguous. Combos are pinned first (preserving #4164); then providers in registry precedence (OAuth → NoAuth → API-key); then unknown providers in locale-independent code-unit order. The sort is stable and pure (reorders rows only, no mutation, no DB/IO), preserving combo `sort_order`, connection priority, custom append-order, and equal-id audio twins. diff --git a/src/app/api/v1/models/catalogOrder.ts b/src/app/api/v1/models/catalogOrder.ts new file mode 100644 index 0000000000..c0793911e8 --- /dev/null +++ b/src/app/api/v1/models/catalogOrder.ts @@ -0,0 +1,98 @@ +/** + * catalogOrder.ts — canonical provider-grouped ordering for GET /v1/models. + * + * The catalog is assembled by many independent push loops (auto-combos, named + * combos, static registry, codex-native, synced, OpenRouter, specialty registries, + * custom, alias-backed, connection-fallback). The same provider appears in several + * loops with other providers interleaved, so its models land in multiple separated + * blocks. This module applies ONE stable, provider-grouped sort at serialization. + * + * Group key = owned_by (the public owner identity), NOT the model-id prefix. + * Built-in providers use their canonical id as owned_by; compatible nodes use the + * configured node prefix. A single routable public prefix can differ from its owner: + * no-auth OpenCode publishes `oc/` while retaining owned_by "opencode". + * Grouping by the prefix would split one provider's models; grouping by owned_by + * keeps them contiguous. + * + * Order: combo block (owned_by === "combo") pinned first, preserving #4164; then + * providers in registry precedence (OAUTH -> NOAUTH -> APIKEY canonical keys); then + * unknown providers by locale-independent code-unit order. Within a group the input + * order is preserved (stable), keeping combo sort_order, connection priority, custom + * append-order, and equal-id audio twins. + * + * Reorders rows only. Identity, alias mapping, Combo/bare compatibility (#6940/#8530), + * effort-variant scoping, and Claude-mirror gating are untouched. Pure; no DB/IO. + */ + +import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers"; + +/** Canonical provider precedence, keyed by provider id (not alias). Built once. */ +const CANONICAL_PROVIDER_ORDER: readonly string[] = [ + ...Object.keys(OAUTH_PROVIDERS), + ...Object.keys(NOAUTH_PROVIDERS), + ...Object.keys(APIKEY_PROVIDERS), +]; + +/** + * Locale-independent code-unit comparator. UTF-16 code units put uppercase A-Z + * (0x41-0x5A) before lowercase a-z (0x61-0x7A); byte-deterministic, no ICU. + */ +function codeUnitCompare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Combo bucket key, distinct from any real provider id. */ +const COMBO_GROUP = " combo"; + +/** + * Grouping key for a catalog row: "combo" for combo-owned rows, else owned_by + * (canonical identity). Rows with no usable owned_by fall back to the id-prefix; + * that path is defensive only — every published row carries owned_by. + */ +function modelGroupKey(model: Record): string { + const ownedBy = typeof model.owned_by === "string" ? model.owned_by : ""; + if (ownedBy === "combo") return COMBO_GROUP; + if (ownedBy) return ownedBy; + + const id = typeof model.id === "string" ? model.id : ""; + const slash = id.indexOf("/"); + return slash > 0 ? id.slice(0, slash) : id; +} + +/** + * Sort priority for a group key: combo first, then registry precedence, then + * unknown groups (rank Infinity, ordered among themselves by code unit). + */ +function groupSortPriority(groupKey: string): number { + if (groupKey === COMBO_GROUP) return -1; + const idx = CANONICAL_PROVIDER_ORDER.indexOf(groupKey); + return idx >= 0 ? idx : Infinity; +} + +/** + * Stable provider-grouped sort. Does not mutate the input. Deterministic for a + * given input array. + */ +export function sortCatalogModelsProviderGrouped>( + models: T[] +): T[] { + if (!Array.isArray(models) || models.length < 2) return models; + + const annotated = models.map((model, index) => { + const groupKey = modelGroupKey(model); + return { model, groupKey, priority: groupSortPriority(groupKey), index }; + }); + + annotated.sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + // Unknown groups (both Infinity): code-unit order by key. + if (a.priority === Infinity && b.priority === Infinity) { + const cmp = codeUnitCompare(a.groupKey, b.groupKey); + if (cmp !== 0) return cmp; + } + // Stable: preserve input order within a group. + return a.index - b.index; + }); + + return annotated.map((entry) => entry.model); +} diff --git a/src/app/api/v1/models/catalogResponse.ts b/src/app/api/v1/models/catalogResponse.ts index 597d329370..35b92c5097 100644 --- a/src/app/api/v1/models/catalogResponse.ts +++ b/src/app/api/v1/models/catalogResponse.ts @@ -23,6 +23,7 @@ import { buildFunctionalGatewayPredicate } from "./functionalGatewayPredicate"; import { getPassthroughProviders, REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; import { dedupeExactCatalogIds } from "./catalogDedupe"; +import { sortCatalogModelsProviderGrouped } from "./catalogOrder"; import { disambiguateCatalogModelNames, enrichCatalogModelEntry, @@ -171,6 +172,12 @@ export function finalizeCatalogResponse( return maybeOmitCatalogModelName(listedModel, includeModelNames); }) ); + // Canonical provider-grouped publication: one contiguous block per provider, + // combos pinned first. Stable — preserves combo sort_order, connection priority, + // and equal-id audio twins. Grouped by owned_by (canonical identity), not the + // routing alias prefix. Applied after enrichment/disambiguation so the final + // serialized order is what every consumer sees; cached as part of the body. + const orderedModels = sortCatalogModelsProviderGrouped(enrichedModels); // Codex CLI compatibility: its model-catalog refresh (codex_models_manager) does // GET /v1/models?client_version= and decodes a JSON object with a TOP-LEVEL // `models` array, so the OpenAI-standard `{object,data}` shape makes it fail with @@ -187,7 +194,7 @@ export function finalizeCatalogResponse( // keeps codex on its built-in model info — same inference as today, minus the error. const responseBody: Record = { object: "list", - data: enrichedModels, + data: orderedModels, }; if (isCodexModelCatalogClient(request)) { responseBody.models = []; diff --git a/tests/unit/catalog-order-contract.test.ts b/tests/unit/catalog-order-contract.test.ts new file mode 100644 index 0000000000..3c25d645cd --- /dev/null +++ b/tests/unit/catalog-order-contract.test.ts @@ -0,0 +1,149 @@ +/** + * tests/unit/catalog-order-contract.test.ts + * + * Provider-grouped ordering contract for the unified model catalog. + * + * Red-first: proves the current tree publishes fragmented provider blocks. + * Uses the same DB module set and reset pattern as models-catalog-route.test.ts. + * /api/models, quota-short-circuit, and inbound-alias cases are split into + * separate files to avoid extra module imports that break the sql.js lifecycle. + */ + +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-catalog-order-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-order-secret"; + +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 combosDb = await import("../../src/lib/db/combos.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function seedConnection(provider: string, overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider, + authType: (overrides.authType as string) || "apikey", + name: `${provider}-test-${Math.random().toString(16).slice(2, 8)}`, + apiKey: (overrides.apiKey as string) || "sk-test", + accessToken: overrides.accessToken as string | undefined, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }) as Promise<{ id: string }>; +} + +function countProviderBlocks(ownedBySequence: string[]): number { + const blocks: string[] = []; + for (const ownedBy of ownedBySequence) { + if (blocks.length === 0 || blocks[blocks.length - 1] !== ownedBy) { + blocks.push(ownedBy); + } + } + return blocks.length; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Exact provider-grouped order: blocks === distinct owned_by +// ───────────────────────────────────────────────────────────────────────────── + +test("catalog /v1/models: exact provider-grouped order (blocks === distinct owned_by)", async () => { + // Seed 3 providers with synced models to guarantee fragmentation if unsorted. + // The static registry also emits models for active providers, so the catalog + // will contain rows from openai, anthropic, and opencode from multiple loops. + const conn1 = await seedConnection("openai"); + const conn2 = await seedConnection("anthropic"); + const conn3 = await seedConnection("opencode"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", (conn1 as any).id, [ + { id: "gpt-4", name: "GPT-4" }, + { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, + ]); + await modelsDb.replaceSyncedAvailableModelsForConnection("anthropic", (conn2 as any).id, [ + { id: "claude-3-opus", name: "Claude 3 Opus" }, + ]); + await modelsDb.replaceSyncedAvailableModelsForConnection("opencode", (conn3 as any).id, [ + { id: "kimi-k2", name: "Kimi K2" }, + { id: "glm-4", name: "GLM-4" }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/v1/models?configuredOnly=true") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: Array<{ owned_by: string }> }; + + // Guarantee rows from all 3 seeded providers are present + const ownedByValues = body.data.map((m) => m.owned_by); + const distinctOwnedBy = new Set(ownedByValues); + assert.ok(distinctOwnedBy.has("openai"), "openai rows present"); + assert.ok(distinctOwnedBy.has("anthropic"), "anthropic rows present"); + assert.ok(distinctOwnedBy.has("opencode"), "opencode rows present"); + + // Exact invariant: each provider appears in exactly one contiguous block + const blockCount = countProviderBlocks(ownedByValues); + assert.equal( + blockCount, + distinctOwnedBy.size, + `Fragmented: ${blockCount} blocks for ${distinctOwnedBy.size} distinct providers. ` + + `Sequence: ${ownedByValues.join(", ")}` + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Combo block pinned first +// ───────────────────────────────────────────────────────────────────────────── + +test("catalog /v1/models: combo block appears first", async () => { + const conn = await seedConnection("openai"); + await modelsDb.replaceSyncedAvailableModelsForConnection("openai", (conn as any).id, [ + { id: "gpt-4", name: "GPT-4" }, + ]); + await combosDb.createCombo({ + name: "test-combo", + modelIds: ["openai/gpt-4"], + strategy: "fallback", + isActive: true, + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/v1/models?configuredOnly=true") + ); + const body = (await response.json()) as { data: Array<{ owned_by: string }> }; + + const hasCombo = body.data.some((m) => m.owned_by === "combo"); + const hasNonCombo = body.data.some((m) => m.owned_by !== "combo"); + assert.ok(hasCombo, "combo rows present"); + assert.ok(hasNonCombo, "non-combo rows present"); + + const firstNonComboIndex = body.data.findIndex((m) => m.owned_by !== "combo"); + const lastComboIndex = body.data.map((m) => m.owned_by).lastIndexOf("combo"); + assert.ok( + lastComboIndex < firstNonComboIndex, + `Combo block not first: last combo at ${lastComboIndex}, first non-combo at ${firstNonComboIndex}` + ); +}); diff --git a/tests/unit/catalog-order-helper.test.ts b/tests/unit/catalog-order-helper.test.ts new file mode 100644 index 0000000000..f01571d1b6 --- /dev/null +++ b/tests/unit/catalog-order-helper.test.ts @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sortCatalogModelsProviderGrouped } from "../../src/app/api/v1/models/catalogOrder.ts"; + +test("combo pinning: combos move to front regardless of input or registry order", () => { + // Combos are at input positions 1 and 3, and "combo" is not a registry key + // (would rank Infinity/unknown without pinning). Asserting they land first + // proves pinning overrides both input order and registry precedence. + const input = [ + { id: "openai/gpt-4", owned_by: "openai" }, + { id: "auto/smart", owned_by: "combo" }, + { id: "anthropic/claude", owned_by: "anthropic" }, + { id: "auto/cheap", owned_by: "combo" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + assert.deepEqual( + out.map((m) => m.id), + ["auto/smart", "auto/cheap", "openai/gpt-4", "anthropic/claude"] + ); +}); + +test("canonical owned_by grouping: oc prefix with opencode owned_by stays contiguous", () => { + const input = [ + { id: "oc/kimi-k2", owned_by: "opencode" }, + { id: "openai/gpt-4", owned_by: "openai" }, + { id: "oc/glm-5", owned_by: "opencode" }, + { id: "openai/gpt-5", owned_by: "openai" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + const keys = out.map((m) => m.owned_by); + assert.equal(keys.lastIndexOf("opencode") - keys.indexOf("opencode"), 1); + assert.equal(keys.lastIndexOf("openai") - keys.indexOf("openai"), 1); +}); + +test("registry precedence over code-unit: qoder before agy (both oauth)", () => { + // oauth.ts key order: qoder (idx 3) before agy (idx 4); code-unit: agy < qoder. + const input = [ + { id: "agy/gemini", owned_by: "agy" }, + { id: "qoder/model", owned_by: "qoder" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + assert.deepEqual( + out.map((m) => m.owned_by), + ["qoder", "agy"] + ); +}); + +test("registry precedence over code-unit: zed (oauth) before openai (apikey)", () => { + // oauth tier precedes apikey tier; code-unit: openai < zed. + const input = [ + { id: "openai/gpt-4", owned_by: "openai" }, + { id: "zed/model", owned_by: "zed" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + assert.deepEqual( + out.map((m) => m.owned_by), + ["zed", "openai"] + ); +}); + +test("unknown providers: deterministic code-unit order", () => { + const input = [ + { id: "zzz-unknown/m1", owned_by: "zzz-unknown" }, + { id: "Zed-unknown/m2", owned_by: "Zed-unknown" }, + { id: "aaa-unknown/m3", owned_by: "aaa-unknown" }, + { id: "9nine-unknown/m4", owned_by: "9nine-unknown" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + // Code-unit: '9'(0x39) < 'Z'(0x5A) < 'a'(0x61) < 'z'(0x7A). + assert.deepEqual( + out.map((m) => m.owned_by), + ["9nine-unknown", "Zed-unknown", "aaa-unknown", "zzz-unknown"] + ); +}); + +test("malformed rows: fallback to id-prefix, exact ordered sequence", () => { + // Empty id ("") is legal by the Record type; group key "" + // sorts first among unknown fallbacks by code unit. Covers missing owned_by, + // empty owned_by, slashless id, and empty id, plus stability within a group. + const input = [ + { id: "openai/gpt-4", owned_by: "openai" }, // registry group + { id: "weird/model", owned_by: "" }, // empty owned_by → prefix "weird" + { id: "slashless", owned_by: "" }, // empty owned_by, no slash → "slashless" + { id: "openai/gpt-5", owned_by: "openai" }, // registry group + { id: "weird/model2" }, // missing owned_by → prefix "weird" + { id: "", owned_by: "" }, // empty id → group key "" + ]; + const out = sortCatalogModelsProviderGrouped(input); + // openai (finite registry rank) first in input order; then unknown fallbacks + // by code-unit of group key: "" < "slashless" < "weird"; stable within "weird". + assert.deepEqual( + out.map((m) => m.id), + ["openai/gpt-4", "openai/gpt-5", "", "slashless", "weird/model", "weird/model2"] + ); +}); + +test("stability: equal-ID twins preserve input order", () => { + const input = [ + { id: "openai/gpt-4", owned_by: "openai", subtype: "chat" }, + { id: "openai/gpt-4", owned_by: "openai", subtype: "speech" }, + { id: "openai/gpt-3.5", owned_by: "openai" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + assert.equal(out[0].subtype, "chat"); + assert.equal(out[1].subtype, "speech"); + assert.equal(out[2].id, "openai/gpt-3.5"); +}); + +test("non-mutation: input array and elements untouched", () => { + const input = [ + { id: "openai/gpt-4", owned_by: "openai" }, + { id: "anthropic/claude", owned_by: "anthropic" }, + ]; + const snapshot = JSON.parse(JSON.stringify(input)); + const out = sortCatalogModelsProviderGrouped(input); + assert.deepEqual(input, snapshot); + assert.notEqual(out, input); + assert.equal(out.length, input.length); +}); + +test("no drops or dupes: every row present exactly once", () => { + const input = [ + { id: "openai/gpt-4", owned_by: "openai" }, + { id: "auto/x", owned_by: "combo" }, + { id: "anthropic/claude", owned_by: "anthropic" }, + { id: "weird/m", owned_by: "" }, + { id: "openai/gpt-5", owned_by: "openai" }, + ]; + const out = sortCatalogModelsProviderGrouped(input); + assert.equal(out.length, input.length); + assert.deepEqual( + out.map((m) => m.id).sort(), + input.map((m) => m.id).sort() + ); +});