fix(models): canonical provider-grouped catalog ordering (#9215)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
This commit is contained in:
nguyenha935
2026-08-06 07:44:52 +07:00
committed by GitHub
parent d5aa7e318f
commit 3afd9bc119
5 changed files with 391 additions and 1 deletions

View File

@@ -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/<model>` 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.

View File

@@ -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/<model>` 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, unknown>): 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<T extends Record<string, unknown>>(
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);
}

View File

@@ -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=<v> 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<string, unknown> = {
object: "list",
data: enrichedModels,
data: orderedModels,
};
if (isCodexModelCatalogClient(request)) {
responseBody.models = [];

View File

@@ -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<string, unknown> = {}) {
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}`
);
});

View File

@@ -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<string, unknown> 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()
);
});