From c0c55ed7c03188f264ff2c27b9982f2d57d092cc Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 20 May 2026 22:32:41 +0900 Subject: feat: key usage panel — display usage data for Claude, OpenCode, and Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'Key Usage' to the sidebar dropdown with per-provider live usage: - Claude: 5-hour and weekly utilization bars with reset timestamps (normalizes Anthropic's 0-100% API response to 0-1 internally) - OpenCode: Scrapes usage from workspace page via OPENCODE_COOKIE session cookie, mapping key IDs to OPENCODE_WS1_ID/OPENCODE_WS2_ID - Copilot: Fetches from api.github.com/copilot_internal/user with entitlement/remaining/quota reset tracking New files: opencode.ts, copilot.ts (usage fetchers), KeyUsage.svelte New route: GET /models/key-usage?keyId=X dispatches by provider --- packages/api/src/routes/models.ts | 121 ++++++++- packages/core/src/credentials/claude.ts | 4 +- packages/core/src/credentials/copilot.ts | 64 +++++ packages/core/src/credentials/index.ts | 29 ++- packages/core/src/credentials/opencode.ts | 129 ++++++++++ .../frontend/src/lib/components/KeyUsage.svelte | 282 +++++++++++++++++++++ .../src/lib/components/SidebarPanel.svelte | 7 +- packages/frontend/src/lib/types.ts | 32 +++ 8 files changed, 646 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/credentials/copilot.ts create mode 100644 packages/core/src/credentials/opencode.ts create mode 100644 packages/frontend/src/lib/components/KeyUsage.svelte (limited to 'packages') diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 2e002c9..b79a45d 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,13 +1,15 @@ -import { Hono } from "hono"; import type { ModelRegistry, ModelResolver } from "@dispatch/core"; import { + ANTHROPIC_MODELS_FALLBACK, type ClaudeAccount, discoverClaudeAccounts, - validateAccountCredentials, fetchAnthropicModels, - ANTHROPIC_MODELS_FALLBACK, + fetchCopilotUsage, + fetchOpencodeUsage, getAccountUsage, + validateAccountCredentials, } from "@dispatch/core"; +import { Hono } from "hono"; let getRegistry: () => ModelRegistry | null = () => null; let getResolver: () => ModelResolver | null = () => null; @@ -103,9 +105,7 @@ modelsRoutes.get("/available", async (c) => { if (key.definition.provider === "anthropic") { const credFile = key.definition.credentials_file; const accounts = discoverClaudeAccounts(); - const account = credFile - ? accounts.find((a) => a.source === credFile) - : accounts[0]; + const account = credFile ? accounts.find((a) => a.source === credFile) : accounts[0]; if (!account) { return c.json({ error: "no Claude credentials found" }, 500); @@ -113,7 +113,13 @@ modelsRoutes.get("/available", async (c) => { const profile = await validateAccountCredentials(account); if (!profile) { - return c.json({ error: "Claude credentials are invalid or expired", details: "Run `claude` to re-authenticate." }, 401); + return c.json( + { + error: "Claude credentials are invalid or expired", + details: "Run `claude` to re-authenticate.", + }, + 401, + ); } const creds = account.credentials; @@ -152,7 +158,10 @@ modelsRoutes.get("/available", async (c) => { if (!response.ok) { const text = await response.text().catch(() => ""); - return c.json({ error: "provider API returned error", status: response.status, details: text }, 502); + return c.json( + { error: "provider API returned error", status: response.status, details: text }, + 502, + ); } let data: { data: { id: string }[] }; @@ -224,4 +233,98 @@ modelsRoutes.get("/claude-usage", async (c) => { } return c.json(report); -}); \ No newline at end of file +}); + +// Get usage for a specific key by ID +modelsRoutes.get("/key-usage", async (c) => { + const keyId = c.req.query("keyId"); + if (!keyId) { + return c.json({ error: "keyId query parameter is required" }, 400); + } + + const registry = getRegistry(); + if (!registry) { + return c.json({ error: "registry not available" }, 502); + } + + const keys = registry.getKeys(); + const key = keys.find((k) => k.definition.id === keyId); + if (!key) { + return c.json({ error: `key not found: ${keyId}` }, 404); + } + + const provider = key.definition.provider; + + try { + if (provider === "anthropic") { + const accounts = discoverClaudeAccounts(); + let account: ClaudeAccount | undefined; + if (key.definition.credentials_file) { + account = accounts.find((a) => a.source === key.definition.credentials_file); + } + if (!account) { + account = accounts[0]; + } + if (!account) { + return c.json({ error: "no Claude accounts available" }, 502); + } + const report = await getAccountUsage(account); + if (!report) { + return c.json({ error: "failed to fetch usage data" }, 502); + } + return c.json({ + provider: "anthropic", + fiveHour: report.fiveHour, + sevenDay: report.sevenDay, + }); + } else if (provider === "opencode-go") { + // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus + // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix). + const report = await fetchOpencodeUsage(key.definition.id); + if (report) { + return c.json({ + provider: "opencode-go", + fiveHour: report.fiveHour, + weekly: report.weekly, + monthly: report.monthly, + }); + } + // Fall back: show limits info with link to console + return c.json({ + provider: "opencode-go", + unavailable: true, + consoleUrl: "https://opencode.ai/auth", + limits: { + fiveHour: "$12", + weekly: "$30", + monthly: "$60", + }, + }); + } else if (provider === "github-copilot") { + if (!key.definition.env) { + return c.json({ error: "no env var configured for this key" }, 502); + } + const token = process.env[key.definition.env]; + if (!token) { + return c.json({ error: `env var ${key.definition.env} not set` }, 502); + } + const report = await fetchCopilotUsage(token, key.definition.base_url); + if (!report) { + return c.json({ error: "failed to fetch usage data" }, 502); + } + return c.json({ + provider: "github-copilot", + tokensConsumed: report.tokensConsumed, + tokensRemaining: report.tokensRemaining, + percentUsed: report.percentUsed, + resetAt: report.resetAt, + plan: report.plan, + }); + } else { + return c.json({ error: "usage tracking not supported for this provider" }, 400); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ error: `failed to fetch usage: ${message}` }, 502); + } +}); diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 87568e6..cc6daec 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -429,7 +429,9 @@ async function fetchClaudeUsage(accessToken: string): Promise { if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined; const b = bucket as Record; - const utilization = typeof b.utilization === "number" ? b.utilization : undefined; + // API returns utilization as 0-100 percentage; normalize to 0-1 fraction + const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined; + const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined; const resetsAt = typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined; if (utilization === undefined && resetsAt === undefined) return undefined; return { utilization, resetsAt }; diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts new file mode 100644 index 0000000..8baf6f4 --- /dev/null +++ b/packages/core/src/credentials/copilot.ts @@ -0,0 +1,64 @@ +// ─── GitHub Copilot Usage Tracking ─────────────────────────── +// Uses the internal GitHub Copilot user endpoint (same one the +// official VS Code Copilot extension calls). + +export interface CopilotUsageReport { + tokensConsumed?: number; + tokensRemaining?: number; + percentUsed?: number; // 0-100 + resetAt?: number; // Unix timestamp ms + plan?: string; +} + +export async function fetchCopilotUsage( + token: string, + _baseUrl: string, +): Promise { + const url = "https://api.github.com/copilot_internal/user"; + const headers: Record = { + authorization: `Bearer ${token}`, + accept: "application/json", + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) return null; + + const data = (await response.json()) as Record; + const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined; + + const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined; + const resetAt = resetDate ? Date.parse(resetDate) : undefined; + + const qs = data.quota_snapshots as Record | undefined; + const pi = qs?.premium_interactions as Record | undefined; + + const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined; + const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined; + const percentRemaining = + typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined; + + if (entitlement === undefined && remaining === undefined) { + return null; + } + + const tokensConsumed = + entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined; + const percentUsed = + percentRemaining !== undefined + ? Math.round((100 - percentRemaining) * 100) / 100 + : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0 + ? Math.round((tokensConsumed / entitlement) * 10000) / 100 + : undefined; + + return { + tokensConsumed, + tokensRemaining: remaining, + percentUsed, + resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined, + plan, + }; + } catch { + return null; + } +} diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index d72ad48..0ae4edb 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -1,18 +1,27 @@ export { - type ClaudeCredentials, + ANTHROPIC_MODELS_FALLBACK, + buildBillingHeaderValue, type ClaudeAccount, + type ClaudeCredentials, + type ClaudeProfile, type ClaudeUsageBucket, type ClaudeUsageReport, - type ClaudeProfile, - ANTHROPIC_MODELS_FALLBACK, - fetchAnthropicModels, discoverClaudeAccounts, - refreshAccountCredentials, - refreshAccountCredentialsAsync, - validateAccountCredentials, - buildBillingHeaderValue, + fetchAnthropicModels, + getAccountUsage, getAnthropicBetas, getAnthropicHeaders, - getAccountUsage, + refreshAccountCredentials, + refreshAccountCredentialsAsync, SYSTEM_IDENTITY, -} from "./claude.js"; \ No newline at end of file + validateAccountCredentials, +} from "./claude.js"; +export { + type CopilotUsageReport, + fetchCopilotUsage, +} from "./copilot.js"; +export { + fetchOpencodeUsage, + type OpencodeUsageBucket, + type OpencodeUsageReport, +} from "./opencode.js"; diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts new file mode 100644 index 0000000..7a74486 --- /dev/null +++ b/packages/core/src/credentials/opencode.ts @@ -0,0 +1,129 @@ +// ─── OpenCode Usage Tracking ────────────────────────────────── +// OpenCode has no public usage API. We scrape usage from the +// SolidStart SSR-rendered workspace page using a session cookie. +// Requires OPENCODE_COOKIE env var. +// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2. + +export interface OpencodeUsageBucket { + utilization?: number; // 0-1 fraction + resetsAt?: number; // Unix timestamp ms +} + +export interface OpencodeUsageReport { + fiveHour?: OpencodeUsageBucket; + weekly?: OpencodeUsageBucket; + monthly?: OpencodeUsageBucket; +} + +function getWorkspaceId(keyId: string): string | undefined { + // Match ai-usage convention: opencode-1 → OPENCODE_WS1_ID, opencode-2 → OPENCODE_WS2_ID + const match = keyId.match(/opencode-(\d+)$/i); + if (match) { + const num = match[1]; + const specific = process.env[`OPENCODE_WS${num}_ID`]; + if (specific) return specific; + } + return process.env.OPENCODE_WS_ID; +} + +function parseOcDouble(html: string, key: string): number { + const idx = html.indexOf(`${key}:`); + if (idx === -1) return 0; + let start = idx + key.length + 1; + while (start < html.length && html[start] === " ") start++; + let end = start; + while (end < html.length && html[end] !== "," && html[end] !== "}") { + end++; + } + const val = parseFloat(html.slice(start, end)); + return Number.isNaN(val) ? 0 : val; +} + +function parseOcInt(html: string, key: string): number { + const idx = html.indexOf(`${key}:`); + if (idx === -1) return 0; + let i = idx + key.length + 1; + while (i < html.length && html[i] === " ") i++; + return parseInt(html.slice(i), 10) || 0; +} + +function parseOcBucket( + html: string, + bucketName: string, +): { utilization: number; resetsAt: number } | null { + const search = `${bucketName}:`; + const pos = html.indexOf(search); + if (pos === -1) return null; + + // Find the opening brace after the bucket name + const brace = html.indexOf("{", pos); + if (brace === -1) return null; + + const resetSecs = parseOcInt(html.slice(brace), "resetInSec"); + const usagePct = parseOcDouble(html.slice(brace), "usagePercent"); + + const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction + const resetsAt = Date.now() + resetSecs * 1000; + + return { utilization, resetsAt }; +} + +export async function fetchOpencodeUsage( + keyId: string, +): Promise { + const cookie = process.env.OPENCODE_COOKIE; + const wsId = getWorkspaceId(keyId); + + if (!cookie || !wsId) { + return null; + } + + const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`; + + try { + const response = await fetch(url, { + headers: { + accept: "text/html", + cookie: `auth=${cookie}`, + }, + redirect: "follow", + }); + + if (!response.ok) return null; + + const html = await response.text(); + + // Auth redirect check + if ( + html.includes("/auth/authorize") || + html.includes('window.location="/auth/authorize"') + ) { + return null; + } + + // Find the lite.subscription data block. + // HTML contains: lite.subscription.get[\"\"] + // We need literal backslashes; use \x5c (hex for backslash). + const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`; + const wsPos = html.indexOf(wsKey); + if (wsPos === -1) return null; + + // Search for the resolved data starting from the ws key position + const minePos = html.indexOf("mine:", wsPos); + const slice = minePos !== -1 ? html.slice(minePos) : ""; + + const fiveHour = parseOcBucket(slice, "rollingUsage"); + const weekly = parseOcBucket(slice, "weeklyUsage"); + const monthly = parseOcBucket(slice, "monthlyUsage"); + + if (!fiveHour && !weekly && !monthly) return null; + + return { + fiveHour: fiveHour ?? undefined, + weekly: weekly ?? undefined, + monthly: monthly ?? undefined, + }; + } catch { + return null; + } +} diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte new file mode 100644 index 0000000..313c183 --- /dev/null +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -0,0 +1,282 @@ + + +
+ + {#if keys.length > 0} + + {:else} +

No keys available.

+ {/if} + + + {#if loading} +
+ + Loading usage data... +
+ {/if} + + + {#if error} + + {/if} + + + {#if !loading && usageData} + {#if !hasAnyData} +

No usage data available for this key.

+ + {:else if usageData.provider === "anthropic"} +
+

Claude Usage

+ + {#if hasBucketData(usageData.fiveHour)} + {@const bucket = usageData.fiveHour!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} +
+

5-Hour Window

+ +
+ {pct}% + {#if bucket.resetsAt} + Resets: {formatDate(bucket.resetsAt)} + {/if} +
+
+ {/if} + + {#if hasBucketData(usageData.sevenDay)} + {@const bucket = usageData.sevenDay!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} +
+

Weekly (7-Day)

+ +
+ {pct}% + {#if bucket.resetsAt} + Resets: {formatDate(bucket.resetsAt)} + {/if} +
+
+ {/if} +
+ + {:else if usageData.provider === "opencode-go"} +
+

OpenCode Usage

+ + {#if usageData.unavailable} +

+ OpenCode does not expose usage data via API. +

+ {#if usageData.limits} +
+

Rate Limits

+
    + {#if usageData.limits.fiveHour} +
  • 5-Hour: {usageData.limits.fiveHour}
  • + {/if} + {#if usageData.limits.weekly} +
  • Weekly: {usageData.limits.weekly}
  • + {/if} + {#if usageData.limits.monthly} +
  • Monthly: {usageData.limits.monthly}
  • + {/if} +
+
+ {/if} + {#if usageData.consoleUrl} + + View usage in console + + {/if} + {:else} + + {#if hasBucketData(usageData.fiveHour)} + {@const bucket = usageData.fiveHour!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} +
+

5-Hour Window

+ +
+ {pct}% + {#if bucket.resetsAt} + Resets: {formatDate(bucket.resetsAt)} + {/if} +
+
+ {/if} + + {#if hasBucketData(usageData.weekly)} + {@const bucket = usageData.weekly!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} +
+

Weekly

+ +
+ {pct}% + {#if bucket.resetsAt} + Resets: {formatDate(bucket.resetsAt)} + {/if} +
+
+ {/if} + + {#if hasBucketData(usageData.monthly)} + {@const bucket = usageData.monthly!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} +
+

Monthly

+ +
+ {pct}% + {#if bucket.resetsAt} + Resets: {formatDate(bucket.resetsAt)} + {/if} +
+
+ {/if} + {/if} +
+ + {:else if usageData.provider === "github-copilot"} + {@const pct = Math.round(usageData.percentUsed ?? 0)} +
+

Copilot Usage

+
+ +
+ {#if usageData.tokensConsumed !== undefined && usageData.tokensRemaining !== undefined} + + {usageData.tokensConsumed.toLocaleString()} / {(usageData.tokensConsumed + usageData.tokensRemaining).toLocaleString()} ({pct}%) + + {:else} + {pct}% + {/if} + {#if usageData.resetAt} + Resets: {formatDate(usageData.resetAt)} + {/if} +
+
+
+ {/if} + {/if} +
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index fe92486..2184818 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import ConfigPanel from "./ConfigPanel.svelte"; import SkillsBrowser from "./SkillsBrowser.svelte"; import PermissionLog from "./PermissionLog.svelte"; + import KeyUsage from "./KeyUsage.svelte"; import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js"; const { @@ -24,7 +25,7 @@ let selected = $state("Tasks"); - const options = ["Model Status", "Tasks", "Config", "Skills", "Permission Log"]; + const options = ["Key Usage", "Model Status", "Tasks", "Config", "Skills", "Permission Log"];
@@ -38,7 +39,9 @@
- {#if selected === "Model Status"} + {#if selected === "Key Usage"} + + {:else if selected === "Model Status"} {:else if selected === "Tasks"} diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 9c83eac..e7ff4ba 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -108,3 +108,35 @@ export interface LogEntry { timestamp: string; description: string; } + +export interface UsageBucket { + utilization?: number; + resetsAt?: number; +} + +export interface ClaudeUsageData { + provider: "anthropic"; + fiveHour?: UsageBucket; + sevenDay?: UsageBucket; +} + +export interface OpencodeUsageData { + provider: "opencode-go"; + unavailable?: boolean; + consoleUrl?: string; + limits?: { fiveHour?: string; weekly?: string; monthly?: string }; + fiveHour?: UsageBucket; + weekly?: UsageBucket; + monthly?: UsageBucket; +} + +export interface CopilotUsageData { + provider: "github-copilot"; + tokensConsumed?: number; + tokensRemaining?: number; + percentUsed?: number; + resetAt?: number; + plan?: string; +} + +export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData; -- cgit v1.2.3