diff --git a/changelog.d/fixes/7764-quota-window-order.md b/changelog.d/fixes/7764-quota-window-order.md new file mode 100644 index 0000000000..297131ed28 --- /dev/null +++ b/changelog.d/fixes/7764-quota-window-order.md @@ -0,0 +1 @@ +- **fix(usage):** keep session/weekly/monthly quota windows in chronological order on every provider card. The order is now derived from the quota keys themselves instead of a provider whitelist, so Claude, MiniMax, Z.ai and Command Code stop rendering the two bars in opposite positions across sibling accounts ([#7764](https://github.com/diegosouzapw/OmniRoute/issues/7764)) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 60346dc03b..8df84a4a57 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -19,7 +19,7 @@ import { } from "../utils"; import QuotaMiniBar from "../QuotaMiniBar"; import { translateUsageOrFallback, type UsageTranslationValues } from "../i18nFallback"; -import { hasFixedQuotaOrder } from "../quotaParsing"; +import { hasFixedQuotaOrder, hasCanonicalWindowOrder, sortQuotasByWindow } from "../quotaParsing"; const CURRENCY_SYMBOLS: Record = { USD: "$", @@ -92,9 +92,17 @@ export function sortQuotasByRemaining(quotas: any[]): any[] { * parseQuotaData() already established. Every other provider still gets the * remaining-percentage sort. Fixes #6687 (bars re-sorted by % undid the fixed * session/weekly order). + * + * #7764 residual: providers outside that whitelist which nonetheless report + * rolling time windows (claude, minimax, zai, command-code, ...) are ordered + * chronologically via `hasCanonicalWindowOrder`/`sortQuotasByWindow`, so the + * expanded card agrees with the collapsed card (`topQuotas`) and with sibling + * accounts of the same provider. */ export function resolveQuotaDisplayOrder(providerId: string | undefined, quotas: any[]): any[] { - return hasFixedQuotaOrder(providerId) ? [...quotas] : sortQuotasByRemaining(quotas); + if (hasFixedQuotaOrder(providerId)) return [...quotas]; + if (hasCanonicalWindowOrder(quotas)) return sortQuotasByWindow(quotas); + return sortQuotasByRemaining(quotas); } /** Pure helper — slices the sorted quotas down to the visible window. */ diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 63977b1d12..af29a1d6ed 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -22,6 +22,74 @@ export function hasFixedQuotaOrder(providerId: string | undefined): boolean { return id === "codex" || GLM_FAMILY_PROVIDERS.includes(id) || KIMI_CODING_PROVIDERS.includes(id); } +/** + * Canonical chronological rank of a rolling usage window, derived from the + * quota key itself rather than from a provider list. + * + * Providers name the same two windows in mutually incompatible ways — + * `"session (5h)"` (claude, minimax, kimi), `"5 Hours Quota"` (GLM/zai), + * `"five_hour"` (command-code, qwen-token-plan), `"code_5h"` (kimi-coding), + * plain `"session"` (codex) — so matching on the shape of the key is the only + * thing that generalizes. Returns `null` for anything that is not a recognizable + * time window (per-model buckets, credit balances, token counters), which is + * what keeps this from claiming quotas it has no opinion about. + */ +export function quotaWindowRank(name: unknown): number | null { + const key = String(name ?? "") + .trim() + .toLowerCase(); + if (!key) return null; + // Order matters: "mcp_monthly" must not be caught by the weekly probe, and + // "5 Hours Quota" must not be caught by anything before the session probe. + if (/month/.test(key)) return 2; + if (/week|7\s*d\b|_7d\b|seven[_\s-]?day/.test(key)) return 1; + if (/session|hour|\b5\s*h\b|_5h\b/.test(key)) return 0; + return null; +} + +/** + * #7764: whether a quota list is a set of rolling time windows whose relative + * order is inherent (session before weekly before monthly) and must therefore + * survive rendering. + * + * This is the structural counterpart to the provider whitelist above. The + * whitelist exists because a few providers need an order the window rank cannot + * express (Codex interleaves GPT-5.3-Codex-Spark windows and a banked-credit + * row between the canonical ones), but it went stale the moment any other + * provider started reporting session+weekly — claude, minimax, zai and + * command-code all do. Deriving the answer from the data means the next such + * provider is covered on arrival. + * + * Requires at least two DISTINCT ranks: with a single window there is no pair + * to keep stable, so the pre-existing worst-status-first sort is left alone. + */ +export function hasCanonicalWindowOrder(quotas: unknown): boolean { + if (!Array.isArray(quotas)) return false; + const ranks = new Set(); + for (const quota of quotas) { + if (!quota || (quota as any).isCredits) continue; + const rank = quotaWindowRank((quota as any).name); + if (rank !== null) ranks.add(rank); + } + return ranks.size >= 2; +} + +/** + * Stable sort of a quota list into canonical window order. Unrecognized entries + * (credits, token counters, per-model buckets) sink below the windows while + * keeping their relative order, so nothing is lost or shuffled. + */ +export function sortQuotasByWindow(quotas: T[]): T[] { + return [...quotas] + .map((quota, index) => ({ quota, index })) + .sort((a, b) => { + const ra = quotaWindowRank((a.quota as any)?.name) ?? 99; + const rb = quotaWindowRank((b.quota as any)?.name) ?? 99; + return ra - rb || a.index - b.index; + }) + .map((entry) => entry.quota); +} + function quotaEntries(data: any): Array<[string, any]> { return data?.quotas && typeof data.quotas === "object" ? Object.entries(data.quotas) : []; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index dc102936d0..5f052623ef 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -1,5 +1,5 @@ export { parseQuotaData } from "./quotaParsing"; -import { hasFixedQuotaOrder } from "./quotaParsing"; +import { hasFixedQuotaOrder, hasCanonicalWindowOrder, sortQuotasByWindow } from "./quotaParsing"; const PROVIDER_PLAN_FALLBACKS = new Set([ "claude code", @@ -400,6 +400,15 @@ export function topQuotas(quotas: any[], n = 3, providerId?: string): any[] { return filtered.slice(0, n); } + // #7764 residual: any OTHER provider reporting rolling time windows (claude, + // minimax, zai, command-code, ...) has an equally inherent session→weekly→ + // monthly order. Re-sorting those by remaining % makes two accounts of the + // same provider render the bars in opposite positions. Detected from the + // quota keys, so a new provider needs no list update. + if (hasCanonicalWindowOrder(filtered)) { + return sortQuotasByWindow(filtered).slice(0, n); + } + return [...filtered] .sort((a, b) => { const sa = STATUS_ORDER[quotaStatus(a)]; diff --git a/tests/unit/repro-7764-collapsed-quota-order.test.ts b/tests/unit/repro-7764-collapsed-quota-order.test.ts index 45fa489e6c..ec75a478dd 100644 --- a/tests/unit/repro-7764-collapsed-quota-order.test.ts +++ b/tests/unit/repro-7764-collapsed-quota-order.test.ts @@ -5,6 +5,7 @@ import { parseQuotaData, hasFixedQuotaOrder, } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing"; +import { resolveQuotaDisplayOrder } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded"; const quotaName = (quota: { name: string }) => quota.name; @@ -62,3 +63,128 @@ test("#7764: providers WITHOUT a fixed order still sort worst-status-first (no r const rendered = topQuotas(quotas, 3, "some-other-provider").map(quotaName); assert.deepEqual(rendered, ["beta", "gamma", "alpha"]); }); + +// --------------------------------------------------------------------------- +// #7764 residual: the original fix only whitelisted codex / GLM family / Kimi +// Coding in `hasFixedQuotaOrder`. Every OTHER provider that reports the same +// session + weekly rolling windows still gets re-sorted by remaining %, so two +// accounts of the SAME provider render the two bars in opposite positions +// depending on which window happens to be more depleted — the exact symptom in +// the report ("the indicators are not located in the same position per card"). +// +// Quota names below are the real upstream keys, not simplified ones: +// claude → open-sse/services/usage/claude.ts:107,112 "session (5h)" / "weekly (7d)" +// minimax → open-sse/services/usage/minimax.ts:312,325 "session (5h)" / "weekly (7d)" +// zai → routed to getGlmUsage (open-sse/services/usage.ts:191-194) +// so it emits "5 Hours Quota" / "Weekly Quota" (glm.ts:33-34) +// command-code → open-sse/services/usage/command-code.ts:193,196 "five_hour" / "weekly" +// --------------------------------------------------------------------------- + +/** Two refreshes of the same account family: in A the weekly window is the + * depleted one, in B it is the session window. A remaining-% sort flips the + * row order between the two; a canonical window order does not. */ +function windowPair(sessionKey: string, weeklyKey: string) { + return { + depletedWeekly: { + quotas: { + [sessionKey]: { used: 9, total: 100, remainingPercentage: 91, resetAt: null }, + [weeklyKey]: { used: 97, total: 100, remainingPercentage: 3, resetAt: null }, + }, + }, + depletedSession: { + quotas: { + [sessionKey]: { used: 99, total: 100, remainingPercentage: 1, resetAt: null }, + [weeklyKey]: { used: 43, total: 100, remainingPercentage: 57, resetAt: null }, + }, + }, + }; +} + +const WINDOW_PROVIDERS: Array<{ provider: string; session: string; weekly: string }> = [ + { provider: "claude", session: "session (5h)", weekly: "weekly (7d)" }, + { provider: "minimax", session: "session (5h)", weekly: "weekly (7d)" }, + { provider: "minimax-cn", session: "session (5h)", weekly: "weekly (7d)" }, + { provider: "zai", session: "5 Hours Quota", weekly: "Weekly Quota" }, + { provider: "command-code", session: "five_hour", weekly: "weekly" }, +]; + +for (const { provider, session, weekly } of WINDOW_PROVIDERS) { + test(`#7764 residual: ${provider} keeps session before weekly in the collapsed card across refreshes`, () => { + const { depletedWeekly, depletedSession } = windowPair(session, weekly); + const parsedA = parseQuotaData(provider, depletedWeekly); + const parsedB = parseQuotaData(provider, depletedSession); + + // parseQuotaData already yields the canonical upstream order for both. + assert.deepEqual(parsedA.map(quotaName), [session, weekly]); + assert.deepEqual(parsedB.map(quotaName), [session, weekly]); + + assert.deepEqual( + topQuotas(parsedA, 3, provider).map(quotaName), + [session, weekly], + `${provider}: collapsed card must not reorder rolling windows by remaining %` + ); + assert.deepEqual( + topQuotas(parsedB, 3, provider).map(quotaName), + [session, weekly], + `${provider}: window order must be identical on the sibling account` + ); + }); + + test(`#7764 residual: ${provider} expanded card window order matches the collapsed card`, () => { + const { depletedWeekly, depletedSession } = windowPair(session, weekly); + const parsedA = parseQuotaData(provider, depletedWeekly); + const parsedB = parseQuotaData(provider, depletedSession); + + assert.deepEqual(resolveQuotaDisplayOrder(provider, parsedA).map(quotaName), [session, weekly]); + assert.deepEqual(resolveQuotaDisplayOrder(provider, parsedB).map(quotaName), [session, weekly]); + }); +} + +test("#7764 residual: a card whose quotas are NOT rolling windows still sorts worst-first", () => { + // Antigravity-style per-model buckets: no canonical chronological order + // exists, so the worst-status-first sort remains the useful one. + const parsed = parseQuotaData("antigravity", { + quotas: { + "gemini-3-pro": { used: 10, total: 100, remainingPercentage: 90 }, + "gemini-3-flash": { used: 95, total: 100, remainingPercentage: 5 }, + }, + }); + assert.deepEqual(topQuotas(parsed, 3, "antigravity").map(quotaName), [ + "gemini-3-flash", + "gemini-3-pro", + ]); +}); + +test("#7764 residual: a single rolling window plus credits is left to the remaining-% sort", () => { + // Only ONE window → no two windows to keep in a stable relative order, so + // nothing is claimed and the pre-existing behaviour is preserved. + const quotas = [ + { name: "credits", used: 0, total: 0, remainingPercentage: 90, isCredits: true }, + { name: "session (5h)", used: 95, total: 100, remainingPercentage: 5 }, + ]; + assert.deepEqual(topQuotas(quotas, 3, "some-credit-provider").map(quotaName), [ + "session (5h)", + "credits", + ]); +}); + +test("#7764 residual: Claude per-model weekly windows keep upstream order and credits sink last", () => { + // Anthropic reports extra `weekly (7d)` buckets plus an extra_usage + // credits row. The window sort must be STABLE: same-rank siblings keep the + // order parseQuotaData produced, and the credits row is not promoted. + const parsed = parseQuotaData("claude", { + quotas: { + "session (5h)": { used: 9, total: 100, remainingPercentage: 91 }, + "weekly (7d)": { used: 97, total: 100, remainingPercentage: 3 }, + "weekly designer (7d)": { used: 50, total: 100, remainingPercentage: 50 }, + }, + extraUsage: { is_enabled: true, monthly_limit: 100, used_credits: 10, utilization: 10 }, + }); + + assert.deepEqual(topQuotas(parsed, 4, "claude").map(quotaName), [ + "session (5h)", + "weekly (7d)", + "weekly designer (7d)", + "extra_usage", + ]); +});