diff --git a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md new file mode 100644 index 0000000000..b67fcc0f62 --- /dev/null +++ b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md @@ -0,0 +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) +- 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.ts b/open-sse/services/usage.ts index 3634f7e3b5..7f4a97486a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -71,6 +71,7 @@ import { getFirecrawlUsage } from "./usage/firecrawl.ts"; import { getCommandCodeUsage } from "./usage/command-code.ts"; import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts"; import { getConolUsage } from "./conolUsage.ts"; +import { getAgentrouterUsage } from "./usage/agentrouter.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -138,6 +139,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "command-code", "conol-web", "cnl", + // AgentRouter (New-API) console balance (GET /api/user/self) + "agentrouter", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -244,6 +247,8 @@ export async function getUsageForProvider( case "conol-web": case "cnl": return await getConolUsage(apiKey || accessToken, providerSpecificData); + case "agentrouter": + return await getAgentrouterUsage(id, connection); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/open-sse/services/usage/agentrouter.ts b/open-sse/services/usage/agentrouter.ts new file mode 100644 index 0000000000..56b0509c63 --- /dev/null +++ b/open-sse/services/usage/agentrouter.ts @@ -0,0 +1,73 @@ +/** + * usage/agentrouter.ts — AgentRouter (New-API) balance quota shapes the Provider + * Limits dashboard expects. + * + * Reuses the already-registered preflight/monitor fetcher (OpenAI-style routing + * apiKey vs console System Access Token + New-Api-User id) instead of re-implementing + * 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, `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; + +/** + * 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 whose `remaining` field carries + * the exact dollar amount (never negative, exactly 0 when the wallet is exhausted). + */ +export async function getAgentrouterUsage( + connectionId: string | undefined, + connection: JsonRecord +) { + const quota = (await fetchAgentrouterQuota( + connectionId || "", + connection + )) as AgentrouterQuota | null; + + if (!quota) { + return { + message: + "AgentRouter balance not available. Add the Console API Key + New-API User ID to the connection to view usage.", + }; + } + + // `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: 0, + total: 0, + remaining: remainingUsd, + remainingPercentage, + resetAt: quota.resetAt ?? null, + unlimited: true, + currency: "USD", + displayName: "Wallet Balance (USD)", + }; + + return { + plan: "AgentRouter", + quotas: { balance }, + 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/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 3d89183e28..a2a676b2e2 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -100,6 +100,8 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ // Alibaba Coding Plan (console API key) + Qwen personal Token Plan (console cookie) — #9603 "bailian-coding-plan", "qwen-cloud-token-plan", + // AgentRouter (New-API) console System Access Token + New-Api-User id (providerSpecificData) + "agentrouter", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 73aabdb2f6..910ad3afab 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -524,6 +524,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "bailian-coding-plan", // Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway) "qwen-cloud-token-plan", + // AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId) + "agentrouter", ]; // ── Zod validation at module load (Phase 7.2) ── 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); +}); diff --git a/tests/unit/agentrouter-quota-visibility.test.ts b/tests/unit/agentrouter-quota-visibility.test.ts new file mode 100644 index 0000000000..a6a29b0d0e --- /dev/null +++ b/tests/unit/agentrouter-quota-visibility.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { supportsProviderQuota } from "../../src/shared/utils/providerQuotaVisibility.ts"; +import { + USAGE_FETCHER_PROVIDERS, + getUsageForProvider, +} from "../../open-sse/services/usage.ts"; +import { + getAgentrouterUsage, +} from "../../open-sse/services/usage/agentrouter.ts"; +import { + invalidateAgentrouterQuotaCache, + type AgentrouterQuota, +} from "../../open-sse/services/agentrouterQuotaFetcher.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** + * #10078 — AgentRouter quota was missing from the dashboard: + * - USAGE_SUPPORTED_PROVIDERS (visibility gate) omitted "agentrouter", and + * - USAGE_FETCHER_PROVIDERS + getUsageForProvider (the provider-limits data + * path) had no "agentrouter" case, so /api/usage/provider-limits fell back + * to the generic "Usage API not implemented" message. + * These three assertions are the permanent regression guard (RED before the + * fix, GREEN after). + */ +test("#10078: agentrouter is present in USAGE_SUPPORTED_PROVIDERS", () => { + assert.equal( + USAGE_SUPPORTED_PROVIDERS.includes("agentrouter" as (typeof USAGE_SUPPORTED_PROVIDERS)[number]), + true + ); +}); + +test("#10078: supportsProviderQuota('agentrouter') is true", () => { + assert.equal(supportsProviderQuota("agentrouter"), true); +}); + +test("#10078: agentrouter is present in USAGE_FETCHER_PROVIDERS", () => { + assert.equal( + USAGE_FETCHER_PROVIDERS.includes("agentrouter" as (typeof USAGE_FETCHER_PROVIDERS)[number]), + true + ); +}); + +test("#10078: getUsageForProvider shapes the AgentRouter balance into a USD quota", async () => { + const connectionId = `agentrouter-vis-${Date.now()}`; + globalThis.fetch = (async () => { + return new Response(JSON.stringify({ data: { quota: 250_000 } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const usage = (await getUsageForProvider({ + id: connectionId, + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + })) as { + plan?: string; + quotas?: Record; + remainingUsd?: number; + }; + + assert.equal(usage.plan, "AgentRouter"); + assert.ok(usage.quotas); + const balance = usage.quotas.balance; + assert.ok(balance, "expected a `balance` quota entry"); + assert.equal(balance.displayName, "Wallet Balance (USD)"); + assert.equal(usage.remainingUsd, 0.5); +}); + +test("#10078: getAgentrouterUsage returns a graceful message when console credentials are missing", async () => { + const usage = (await getAgentrouterUsage(`missing-${Date.now()}`, { + provider: "agentrouter", + })) as { message?: string; quotas?: unknown }; + + assert.equal(typeof usage.message, "string"); + assert.ok(/not available/i.test(usage.message || "")); + assert.equal(usage.quotas, undefined); +}); \ No newline at end of file