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); + }); +});