From d68c8c869e796482102c1a0e49a540bbc38e314e Mon Sep 17 00:00:00 2001
From: Dizzle <112548150+maxmad64bis@users.noreply.github.com>
Date: Thu, 10 Sep 2026 14:13:10 +0200
Subject: [PATCH] fix(providers): single source for provider order, resolve xao
rank (#12790)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Validado numa worktree combinada com a onda de roteamento/free-tier desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-api-typecheck OK (288), check-file-size OK após rebaseline, 84/85 nos testes focados.
Duas cópias da mesma ordem de provider com um "keep in sync" implícito é dívida que cobra juros a cada provider novo. Uma definição com re-export nos dois lados resolve a classe.
O `xao/*` ordenando depois de todo provider conhecido em vez de junto do `xai-oauth` é um sintoma concreto de que a duplicação já estava divergindo.
**Integração:** `scripts/quality/run-all-gates.mjs` conflitou com o `check:pricing-freshness` que entrou pelo #12792 na mesma onda. Aditivo — os dois gates coexistem.
---
.../12790-provider-order-single-source.md | 1 +
package.json | 1 +
scripts/check/check-provider-order-sync.mjs | 87 +++++++++++++++++++
scripts/quality/run-all-gates.mjs | 1 +
.../components/modals/QuotaScrapingFields.tsx | 3 +-
.../modals/quotaScrapingFieldValues.ts | 4 +-
.../components/ProviderLimits/quotaParsing.ts | 3 +-
src/app/api/providers/[id]/models/route.ts | 5 +-
src/app/api/v1/models/catalogOrder.ts | 22 +----
src/lib/combos/comboSort.ts | 17 +---
.../constants/canonicalProviderOrder.ts | 48 ++++++++++
tests/unit/canonical-provider-order.test.ts | 59 +++++++++++++
12 files changed, 211 insertions(+), 40 deletions(-)
create mode 100644 changelog.d/fixes/12790-provider-order-single-source.md
create mode 100644 scripts/check/check-provider-order-sync.mjs
create mode 100644 src/shared/constants/canonicalProviderOrder.ts
create mode 100644 tests/unit/canonical-provider-order.test.ts
diff --git a/changelog.d/fixes/12790-provider-order-single-source.md b/changelog.d/fixes/12790-provider-order-single-source.md
new file mode 100644
index 0000000000..6a2c587375
--- /dev/null
+++ b/changelog.d/fixes/12790-provider-order-single-source.md
@@ -0,0 +1 @@
+- **fix(providers):** single source for provider order with `xao` ranking alongside `xai-oauth` ([#12790](https://github.com/diegosouzapw/OmniRoute/pull/12790)) — thanks @maxmad64bis
diff --git a/package.json b/package.json
index 5cbcf71b42..bd3367796d 100644
--- a/package.json
+++ b/package.json
@@ -181,6 +181,7 @@
"check:model-lifecycle": "node --import tsx/esm scripts/check/check-model-lifecycle.mjs",
"check:provider-consistency": "bun scripts/check/check-provider-consistency.ts",
"check:provider-assets": "node scripts/check/check-provider-assets.mjs",
+ "check:provider-order-sync": "node scripts/check/check-provider-order-sync.mjs",
"check:provider-asset-provenance": "node scripts/check/check-provider-asset-provenance.mjs",
"check:nvidia-catalog-drift": "node --import tsx/esm scripts/check/check-nvidia-catalog-drift.ts",
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs",
diff --git a/scripts/check/check-provider-order-sync.mjs b/scripts/check/check-provider-order-sync.mjs
new file mode 100644
index 0000000000..303ca4d43f
--- /dev/null
+++ b/scripts/check/check-provider-order-sync.mjs
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+// Checks the canonical provider order stays single-sourced.
+//
+// STRICT (blocking, exit 1): the order derivation exists exactly once
+// (canonicalProviderOrder.ts); catalogOrder.ts and comboSort.ts re-export it
+// (grep-negative for a local Object.keys(OAUTH derivation); re-export present);
+// the xao alias stays declared under xai-oauth; the unknown-provider contract
+// (?? Infinity + codeUnitCompare) stays live.
+// INFORMATIVE (warn only, always exit 0): dashboard quota ranks in
+// ProviderLimits/constants.ts are a separate display order — a registry id
+// missing there is fine when the provider has no quota surface.
+// Run: node scripts/check/check-provider-order-sync.mjs
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..", "..");
+const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8");
+let failures = 0;
+const fail = (msg) => {
+ failures += 1;
+ console.error(`[provider-order-sync] FAIL ${msg}`);
+};
+
+// 1+2. Leaf uniqueness.
+for (const f of ["src/app/api/v1/models/catalogOrder.ts", "src/lib/combos/comboSort.ts"]) {
+ if (read(f).includes("Object.keys(OAUTH")) {
+ fail(`${f} defines its own order derivation (import the leaf instead)`);
+ }
+}
+// 3. xao alias form.
+const oauth = read("src/shared/constants/providers/oauth.ts");
+if (!/"xai-oauth":\s*\{[^}]*alias:\s*"xao"/s.test(oauth)) {
+ fail(`oauth.ts: "xai-oauth" entry lost its alias:"xao"`);
+}
+// 4. Unknown contract alive.
+if (!read("src/shared/constants/canonicalProviderOrder.ts").includes("?? Infinity")) {
+ fail(`canonicalProviderOrder.ts lost the ?? Infinity unknown contract`);
+}
+if (!read("src/app/api/v1/models/catalogOrder.ts").includes("codeUnitCompare")) {
+ fail(`catalogOrder.ts lost the codeUnitCompare unknown-branch`);
+}
+// 5. Re-export intact.
+if (!read("src/lib/combos/comboSort.ts").includes("CANONICAL_PROVIDER_ORDER as PROVIDER_ORDER")) {
+ fail(`comboSort.ts lost the PROVIDER_ORDER re-export`);
+}
+
+// Informative dashboard check (never fails).
+try {
+ const dash = read("src/app/(dashboard)/dashboard/usage/components/ProviderLimits/constants.ts");
+ const ids = new Set();
+ // NOTE: apikey/index.ts is a barrel (imports + spreads, zero literal entries),
+ // so glob the family files instead.
+ const apikeyFiles = fs
+ .readdirSync(path.join(ROOT, "src/shared/constants/providers/apikey"))
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
+ .map((f) => `src/shared/constants/providers/apikey/${f}`);
+ for (const f of [
+ "src/shared/constants/providers/oauth.ts",
+ "src/shared/constants/providers/noauth.ts",
+ ...apikeyFiles,
+ ]) {
+ let src;
+ try {
+ src = read(f);
+ } catch {
+ continue;
+ }
+ for (const m of src.matchAll(/^ "([^"]+)": \{$/gm)) ids.add(m[1]);
+ }
+ for (const id of [...ids].sort()) {
+ if (!dash.includes(`"${id}"`) && !dash.includes(`${id}:`)) {
+ console.log(
+ `[provider-order-sync] warn: ${id} has no dashboard quota rank (ok if no quota surface)`
+ );
+ }
+ }
+} catch (e) {
+ console.log(`[provider-order-sync] warn: dashboard check skipped (${String(e).slice(0, 120)})`);
+}
+
+if (failures > 0) {
+ process.exit(1);
+}
+console.log("[provider-order-sync] OK — single source + alias form + unknown contract");
diff --git a/scripts/quality/run-all-gates.mjs b/scripts/quality/run-all-gates.mjs
index 409c8c1d29..54102b6abc 100644
--- a/scripts/quality/run-all-gates.mjs
+++ b/scripts/quality/run-all-gates.mjs
@@ -29,6 +29,7 @@ const GATES = [
// Group B — fast (<5s)
{ name: "check:provider-consistency", cmd: ["node", "--import", "tsx", "scripts/check/check-provider-consistency.ts"] },
{ name: "check:provider-assets", cmd: ["node", "scripts/check/check-provider-assets.mjs"] },
+ { name: "check:provider-order-sync", cmd: ["node", "scripts/check/check-provider-order-sync.mjs"] },
{ name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] },
{ name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] },
{ name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] },
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx
index 240eafea50..9fe727f045 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx
@@ -1,6 +1,7 @@
"use client";
import { Input } from "@/shared/components";
+import { getProviderConnectionFamilyIds } from "@/shared/constants/providers";
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
import {
@@ -54,7 +55,7 @@ export default function QuotaScrapingFields({
);
}
- if (provider === "alibaba" || provider === "alibaba-cn") {
+ if (getProviderConnectionFamilyIds("alibaba").includes(provider)) {
return (
= { session: 0, weekly: 1, mcp_monthly: 2 };
@@ -10,7 +11,7 @@ const CODEX_QUOTA_ORDER: Record = {
banked_reset_credits: 4,
};
const GLM_FAMILY_PROVIDERS = ["glm", "glm-cn", "glmt", "opencode-go"];
-const KIMI_CODING_PROVIDERS = ["kimi-coding", "kimi-coding-apikey"];
+const KIMI_CODING_PROVIDERS: readonly string[] = getProviderConnectionFamilyIds("kimi-coding");
/**
* Providers whose quotas already get a deterministic fixed-window order below
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts
index bcf5de3e94..c09a2636b7 100755
--- a/src/app/api/providers/[id]/models/route.ts
+++ b/src/app/api/providers/[id]/models/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import {
+ getProviderConnectionFamilyIds,
isClaudeCodeCompatibleProvider,
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
@@ -2343,7 +2344,7 @@ export async function GET(
const data = await response.json();
let pageModels = config.parseResponse(data);
- if (provider === "alibaba" || provider === "alibaba-cn") {
+ if (getProviderConnectionFamilyIds("alibaba").includes(provider)) {
const { parseAlibabaModelStudioModelsForConnection } =
await import("./discovery/providerModelsConfig.ts");
pageModels = parseAlibabaModelStudioModelsForConnection(
@@ -2372,7 +2373,7 @@ export async function GET(
);
}
- if (provider === "alibaba" || provider === "alibaba-cn") {
+ if (getProviderConnectionFamilyIds("alibaba").includes(provider)) {
const { shouldUseLiveAlibabaFreeModelDiscovery } =
await import("@omniroute/open-sse/services/alibabaFreeTier.ts");
const { scheduleAlibabaFreeTierProbeRefresh } =
diff --git a/src/app/api/v1/models/catalogOrder.ts b/src/app/api/v1/models/catalogOrder.ts
index c0793911e8..7e0bfce672 100644
--- a/src/app/api/v1/models/catalogOrder.ts
+++ b/src/app/api/v1/models/catalogOrder.ts
@@ -24,14 +24,7 @@
* 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),
-];
+import { COMBO_GROUP, groupSortPriority } from "@/shared/constants/canonicalProviderOrder";
/**
* Locale-independent code-unit comparator. UTF-16 code units put uppercase A-Z
@@ -41,9 +34,6 @@ 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;
@@ -59,16 +49,6 @@ function modelGroupKey(model: Record): string {
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.
diff --git a/src/lib/combos/comboSort.ts b/src/lib/combos/comboSort.ts
index 977c45b93c..316c23b875 100644
--- a/src/lib/combos/comboSort.ts
+++ b/src/lib/combos/comboSort.ts
@@ -1,5 +1,5 @@
// src/lib/combos/comboSort.ts
-import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
+import { CANONICAL_PROVIDER_ORDER, providerRank } from "@/shared/constants/canonicalProviderOrder";
import type { ComboStep } from "@/lib/combos/steps";
export type { ComboStep };
@@ -17,13 +17,8 @@ export function isValidSortMethod(raw: unknown): raw is SortMethod {
return VALID_SORT_METHODS.has(raw as string);
}
-// Mirrors CANONICAL_PROVIDER_ORDER from src/app/api/v1/models/catalogOrder.ts — keep in sync.
-// Both are derived from OAUTH+NOAUTH+APIKEY keys; drift would silently diverge catalog vs combo order.
-export const PROVIDER_ORDER: readonly string[] = [
- ...Object.keys(OAUTH_PROVIDERS),
- ...Object.keys(NOAUTH_PROVIDERS),
- ...Object.keys(APIKEY_PROVIDERS),
-];
+// Single source: re-exported from canonicalProviderOrder (was a duplicated derivation).
+export { CANONICAL_PROVIDER_ORDER as PROVIDER_ORDER };
const REFERENCE_SENTINEL = " combo-ref"; // sorts after any real provider id
@@ -40,12 +35,6 @@ function nameKey(step: ComboStep): string {
return step.comboName; // combo-ref
}
-/** Stable index for provider ordering; unknown providers go after the known list. */
-function providerRank(providerId: string): number {
- const idx = PROVIDER_ORDER.indexOf(providerId);
- return idx === -1 ? PROVIDER_ORDER.length : idx;
-}
-
/** Synchronous sorts: manual (noop), provider, name. Stable. */
export function sortComboStepsSync(
steps: ComboStep[],
diff --git a/src/shared/constants/canonicalProviderOrder.ts b/src/shared/constants/canonicalProviderOrder.ts
new file mode 100644
index 0000000000..9ec86ea4d6
--- /dev/null
+++ b/src/shared/constants/canonicalProviderOrder.ts
@@ -0,0 +1,48 @@
+import { OAUTH_PROVIDERS } from "@/shared/constants/providers/oauth";
+import { NOAUTH_PROVIDERS } from "@/shared/constants/providers/noauth";
+import { APIKEY_PROVIDERS } from "@/shared/constants/providers/apikey";
+
+/** Registry-canonical provider precedence: OAUTH -> NOAUTH -> APIKEY (canonical keys, not aliases). */
+export const CANONICAL_PROVIDER_ORDER: readonly string[] = [
+ ...Object.keys(OAUTH_PROVIDERS),
+ ...Object.keys(NOAUTH_PROVIDERS),
+ ...Object.keys(APIKEY_PROVIDERS),
+];
+
+// Single load-time pass (never resolve per sorted item: getProviderByAlias loops
+// ~10 sections x providers, which costs more than the indexOf it replaces on ~1k catalog rows).
+function buildRankResolved(): ReadonlyMap {
+ const rank = new Map();
+ CANONICAL_PROVIDER_ORDER.forEach((id, i) => rank.set(id, i));
+ // Canonical keys win over homonym aliases (m-a: ~17 aliases like minimax-cn
+ // are also canonical keys — an alias must never steal a canonical slot).
+ const canonicals = new Set(CANONICAL_PROVIDER_ORDER);
+ for (const def of Object.values({
+ ...OAUTH_PROVIDERS,
+ ...NOAUTH_PROVIDERS,
+ ...APIKEY_PROVIDERS,
+ })) {
+ const a = (def as { id: string; alias?: string }).alias;
+ if (typeof a === "string" && a.length > 0 && !canonicals.has(a) && rank.has(def.id)) {
+ rank.set(a, rank.get(def.id) as number);
+ }
+ }
+ return rank;
+}
+
+const RANK_RESOLVED: ReadonlyMap = buildRankResolved();
+
+/** Finite rank (combos): alias resolved, unknown -> length (existing comboSort contract). */
+export function providerRank(providerId: string): number {
+ return RANK_RESOLVED.get(providerId) ?? CANONICAL_PROVIDER_ORDER.length;
+}
+
+/** Combo bucket key, distinct from any real provider id. Single definition (was also in catalogOrder.ts:45). */
+export const COMBO_GROUP = " combo";
+
+/** Catalog priority: combo first, then registry precedence, unknown -> Infinity (code-unit branch stays live). */
+export function groupSortPriority(groupKey: string): number {
+ if (groupKey === COMBO_GROUP) return -1;
+ const r = RANK_RESOLVED.get(groupKey);
+ return r ?? Infinity;
+}
diff --git a/tests/unit/canonical-provider-order.test.ts b/tests/unit/canonical-provider-order.test.ts
new file mode 100644
index 0000000000..ab5d93fa08
--- /dev/null
+++ b/tests/unit/canonical-provider-order.test.ts
@@ -0,0 +1,59 @@
+// tests/unit/canonical-provider-order.test.ts
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ CANONICAL_PROVIDER_ORDER,
+ COMBO_GROUP,
+ groupSortPriority,
+ providerRank,
+} from "@/shared/constants/canonicalProviderOrder";
+import { OAUTH_PROVIDERS } from "@/shared/constants/providers/oauth";
+import { NOAUTH_PROVIDERS } from "@/shared/constants/providers/noauth";
+import { APIKEY_PROVIDERS } from "@/shared/constants/providers/apikey";
+import { PROVIDER_ORDER } from "@/lib/combos/comboSort";
+
+describe("canonical provider order", () => {
+ it("matches the registry derivation OAUTH -> NOAUTH -> APIKEY", () => {
+ assert.deepEqual(
+ [...CANONICAL_PROVIDER_ORDER],
+ [
+ ...Object.keys(OAUTH_PROVIDERS),
+ ...Object.keys(NOAUTH_PROVIDERS),
+ ...Object.keys(APIKEY_PROVIDERS),
+ ]
+ );
+ });
+
+ it("resolves the xao alias to the xai-oauth rank", () => {
+ assert.ok(CANONICAL_PROVIDER_ORDER.includes("xai-oauth"));
+ assert.equal(providerRank("xao"), providerRank("xai-oauth"));
+ });
+
+ it("keeps the catalog unknown contract (Infinity, combo first)", () => {
+ assert.equal(groupSortPriority(COMBO_GROUP), -1);
+ assert.equal(groupSortPriority("zzz-no-such-provider"), Infinity);
+ });
+
+ it("keeps the combos unknown contract (length)", () => {
+ assert.equal(providerRank("zzz-no-such-provider"), CANONICAL_PROVIDER_ORDER.length);
+ });
+
+ it("is shared by reference with comboSort", () => {
+ assert.equal(PROVIDER_ORDER, CANONICAL_PROVIDER_ORDER);
+ });
+
+ it("resolves every declared alias to its canonical rank", () => {
+ const seen = new Set();
+ for (const def of Object.values({
+ ...OAUTH_PROVIDERS,
+ ...NOAUTH_PROVIDERS,
+ ...APIKEY_PROVIDERS,
+ }) as { id: string; alias?: string }[]) {
+ if (typeof def.alias === "string" && def.alias.length > 0 && !seen.has(def.alias)) {
+ seen.add(def.alias);
+ assert.equal(providerRank(def.alias), providerRank(def.id));
+ }
+ }
+ assert.ok(seen.size > 0);
+ });
+});