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..e2a3639cde --- /dev/null +++ b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md @@ -0,0 +1 @@ +- 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 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..f6c23c9d21 --- /dev/null +++ b/open-sse/services/usage/agentrouter.ts @@ -0,0 +1,68 @@ +/** + * 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, 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). + */ +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. + */ +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.", + }; + } + + const percentUsed = clamp01(quota.percentUsed); + const remaining = Math.round((1 - percentUsed) * 1000) / 10; + + const balance: UsageQuota = { + used: Math.round(percentUsed * 100), + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: quota.resetAt ?? null, + unlimited: false, + currency: "USD", + displayName: "Wallet Balance (USD)", + }; + + return { + plan: "AgentRouter", + quotas: { balance }, + remainingUsd: quota.dollarBalance, + availableUsd: quota.dollarBalance, + balance: quota.dollarBalance, + }; +} \ No newline at end of file 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 143a11a45d..29a7d7205d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -504,6 +504,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-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