diff --git a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md index e2a3639cde..b67fcc0f62 100644 --- a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md +++ b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md @@ -1 +1,2 @@ -- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078) \ No newline at end of file +- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078) +- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078) \ No newline at end of file diff --git a/open-sse/services/usage/agentrouter.ts b/open-sse/services/usage/agentrouter.ts index f6c23c9d21..56b0509c63 100644 --- a/open-sse/services/usage/agentrouter.ts +++ b/open-sse/services/usage/agentrouter.ts @@ -7,26 +7,29 @@ * the HTTP call, so the 60s in-memory cache in agentrouterQuotaFetcher.ts is shared. * * AgentRouter exposes a raw New-API credit balance, not a real grant to divide by — - * so, following the DeepSeek boolean-availability precedent, the percent is only a - * two-state signal (0 = has balance, 100 = exhausted) and the human-meaningful number - * is the dollar balance (rawQuota / QUOTA_PER_UNIT). + * so, following the DeepSeek boolean-availability precedent, `remainingPercentage` is + * only a two-state signal (100 = has balance, 0 = exhausted) used for the quota-card + * bar color. The human-meaningful number — the actual USD balance (rawQuota / + * QUOTA_PER_UNIT) — MUST travel inside `quotas.balance.remaining` so the Dashboard + * Quota UI's credits-row renderer (quotaParsing.ts::parseAgentrouterQuota, which reads + * `quota.remaining`/`quota.currency`) can format it with a currency symbol instead of + * dropping it: `getUsageForProvider()`'s top-level `remainingUsd`/`availableUsd`/ + * `balance` sibling fields exist for API/CLI consumers only — parseQuotaData() (the + * Dashboard renderer) never reads them, only `data.quotas` (#10078 follow-up). */ import { fetchAgentrouterQuota, type AgentrouterQuota } from "../agentrouterQuotaFetcher.ts"; import { type UsageQuota } from "./quota.ts"; type JsonRecord = Record; -function clamp01(n: number): number { - return Math.max(0, Math.min(1, Number.isFinite(n) ? n : 0)); -} - /** * AgentRouter balance → dashboard usage shape. * * Returns `{ message }` when the fetch returns null (no console credentials, an * upstream error, or a rejected token), which the Provider Limits UI renders as a * graceful per-row status instead of crashing the whole page. Otherwise shapes the - * balance into a single USD `quotas.balance` entry. + * balance into a single USD `quotas.balance` entry whose `remaining` field carries + * the exact dollar amount (never negative, exactly 0 when the wallet is exhausted). */ export async function getAgentrouterUsage( connectionId: string | undefined, @@ -44,16 +47,18 @@ export async function getAgentrouterUsage( }; } - const percentUsed = clamp01(quota.percentUsed); - const remaining = Math.round((1 - percentUsed) * 1000) / 10; + // `dollarBalance` is already `rawQuota / QUOTA_PER_UNIT` (agentrouterQuotaFetcher.ts); + // clamp defensively so an exhausted/mis-parsed wallet never surfaces as negative. + const remainingUsd = Math.max(0, quota.dollarBalance); + const remainingPercentage = quota.limitReached ? 0 : 100; const balance: UsageQuota = { - used: Math.round(percentUsed * 100), - total: 100, - remaining, - remainingPercentage: remaining, + used: 0, + total: 0, + remaining: remainingUsd, + remainingPercentage, resetAt: quota.resetAt ?? null, - unlimited: false, + unlimited: true, currency: "USD", displayName: "Wallet Balance (USD)", }; @@ -61,8 +66,8 @@ export async function getAgentrouterUsage( return { plan: "AgentRouter", quotas: { balance }, - remainingUsd: quota.dollarBalance, - availableUsd: quota.dollarBalance, - balance: quota.dollarBalance, + remainingUsd, + availableUsd: remainingUsd, + balance: remainingUsd, }; } \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 979aafa5bc..3aea687442 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -217,6 +217,27 @@ function parseDeepseek(data: any) { return quotaEntries(data).map(([quotaKey, quota]) => parseDeepseekQuota(quotaKey, quota)); } +// #10078 follow-up: AgentRouter's `quotas.balance` entry (open-sse/services/usage/agentrouter.ts) +// carries a real USD amount in `remaining` + `currency: "USD"`. The generic path +// (normalizeQuotaEntry via parseGeneric) drops `currency` entirely and never sets +// `isCredits`/`creditCount`, so QuotaCardBody/QuotaCardExpanded's dollar-formatted +// renderer (which only activates on `q.isCredits`) never triggers — the balance was +// rendered as a bare "100%/0% left" percentage instead of "$X.XX". Route it through +// buildCreditsQuota() (same shape DeepSeek/Claude-extra-usage credits rows use) so the +// dollar figure — and an exhausted ($0.00) balance — render unambiguously as USD. +function parseAgentrouterQuota(quotaKey: string, quota: any) { + if (quotaKey !== "balance") return normalizeQuotaEntry(quotaKey, quota); + const remaining = Math.max(0, Number(quota?.remaining ?? 0)); + const currency = quota?.currency || "USD"; + const remainingPercentage = + safePercentage(quota?.remainingPercentage) ?? (remaining > 0 ? 100 : 0); + return buildCreditsQuota(currency, remaining, remainingPercentage, { currency }); +} + +function parseAgentrouter(data: any) { + return quotaEntries(data).map(([quotaKey, quota]) => parseAgentrouterQuota(quotaKey, quota)); +} + function parseProviderQuotas(providerId: string, data: any) { if (providerId === "github") return parseGithub(data); if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data); @@ -224,6 +245,7 @@ function parseProviderQuotas(providerId: string, data: any) { if (providerId === "codex") return parseCodex(data); if (providerId === "claude") return parseClaude(data); if (providerId === "deepseek") return parseDeepseek(data); + if (providerId === "agentrouter") return parseAgentrouter(data); return parseGeneric(data); } diff --git a/tests/unit/agentrouter-quota-dashboard-rendering.test.ts b/tests/unit/agentrouter-quota-dashboard-rendering.test.ts new file mode 100644 index 0000000000..4a410be9ad --- /dev/null +++ b/tests/unit/agentrouter-quota-dashboard-rendering.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getAgentrouterUsage } from "../../open-sse/services/usage/agentrouter.ts"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockAgentrouterFetch(rawQuota: number) { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: { quota: rawQuota } }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +/** + * #10078 follow-up — the original fix wired AgentRouter's balance into + * getUsageForProvider()/USAGE_SUPPORTED_PROVIDERS (visibility + data path), but the + * Dashboard Quota UI *renderer* (QuotaCardBody / QuotaCardExpanded under + * src/app/(dashboard)/dashboard/usage/components/ProviderLimits/) only formats a + * quota row as a dollar amount ("$X.XX") when the row carries `isCredits: true` + + * `currency` + `creditCount` — fields the generic quota-parsing path + * (parseGeneric -> normalizeQuotaEntry in quotaParsing.ts) never sets, and never + * copies `currency` through at all. Because "agentrouter" wasn't special-cased in + * parseProviderQuotas(), a configured balance rendered as a bare "100% left" + * percentage (not USD), and the real dollar figure (`dollarBalance`) was only + * exposed as a top-level `remainingUsd`/`availableUsd`/`balance` sibling field that + * parseQuotaData() never reads (it only walks `data.quotas`). + * + * This test drives the real producer (getAgentrouterUsage) through the real + * Dashboard adapter (parseQuotaData) end-to-end — the same path the Provider + * Limits UI takes — and asserts the row the renderer actually consumes + * (`isCredits`, `currency`, `creditCount`) instead of just the wire-shape fields + * asserted by tests/unit/agentrouter-quota-visibility.test.ts. + */ +test("#10078: a configured AgentRouter balance renders as a USD credits row in the Dashboard Quota UI", async () => { + const connectionId = `agentrouter-dash-configured-${Date.now()}`; + mockAgentrouterFetch(250_000); // 250_000 / 500_000 QUOTA_PER_UNIT = $0.50 + + const usage = await getAgentrouterUsage(connectionId, { + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + }); + + const rows = parseQuotaData("agentrouter", usage) as Array<{ + isCredits?: boolean; + currency?: string; + creditCount?: number; + remainingPercentage?: number; + }>; + + assert.equal(rows.length, 1, `expected exactly one quota row, got: ${JSON.stringify(rows)}`); + const [row] = rows; + + // These are exactly the fields QuotaCardBody.tsx / QuotaCardExpanded.tsx branch on + // to render a dollar-formatted amount ("$0.50") instead of a bare percentage. + assert.equal(row.isCredits, true, "renderer only formats USD when isCredits is true"); + assert.equal(row.currency, "USD", "renderer looks up CURRENCY_SYMBOLS[q.currency]"); + assert.equal(row.creditCount, 0.5, "renderer displays q.creditCount as the dollar amount"); + assert.equal(row.remainingPercentage, 100, "a funded wallet must not read as exhausted"); +}); + +test("#10078: an exhausted AgentRouter balance renders as exactly $0, not negative or NaN", async () => { + const connectionId = `agentrouter-dash-exhausted-${Date.now()}`; + mockAgentrouterFetch(0); + + const usage = await getAgentrouterUsage(connectionId, { + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + }); + + const rows = parseQuotaData("agentrouter", usage) as Array<{ + isCredits?: boolean; + currency?: string; + creditCount?: number; + remainingPercentage?: number; + }>; + + assert.equal(rows.length, 1); + const [row] = rows; + + assert.equal(row.isCredits, true); + assert.equal(row.currency, "USD"); + assert.equal(row.creditCount, 0, "exhausted balance must render as exactly zero"); + assert.equal(Number.isFinite(row.creditCount), true, "must never render NaN"); + assert.ok((row.creditCount ?? -1) >= 0, "must never render negative"); + assert.equal(row.remainingPercentage, 0, "exhausted wallet must read as 0% remaining (critical color)"); +}); + +test("#10078: parseQuotaData never drops a raw negative/garbage remaining as -$X — clamps to 0", () => { + // Defends the Math.max(0, ...) clamp in both getAgentrouterUsage() and + // parseAgentrouterQuota() against a malformed/negative upstream `remaining`. + const data = { + plan: "AgentRouter", + quotas: { + balance: { + used: 0, + total: 0, + remaining: -5, + remainingPercentage: 0, + resetAt: null, + unlimited: true, + currency: "USD", + displayName: "Wallet Balance (USD)", + }, + }, + }; + + const rows = parseQuotaData("agentrouter", data) as Array<{ creditCount?: number }>; + assert.equal(rows.length, 1); + assert.equal(rows[0].creditCount, 0); +});