mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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.
|
||||
98
src/app/api/v1/models/catalogOrder.ts
Normal file
98
src/app/api/v1/models/catalogOrder.ts
Normal 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);
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
149
tests/unit/catalog-order-contract.test.ts
Normal file
149
tests/unit/catalog-order-contract.test.ts
Normal 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}`
|
||||
);
|
||||
});
|
||||
135
tests/unit/catalog-order-helper.test.ts
Normal file
135
tests/unit/catalog-order-helper.test.ts
Normal 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()
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user