diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 22:32:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 22:32:41 +0900 |
| commit | c0c55ed7c03188f264ff2c27b9982f2d57d092cc (patch) | |
| tree | 04b871b83e6e1481c8b24b59aa3acc0e15b44f02 | |
| parent | 8151447758e6826a578363758a755c6cebd1c05f (diff) | |
| download | dispatch-c0c55ed7c03188f264ff2c27b9982f2d57d092cc.tar.gz dispatch-c0c55ed7c03188f264ff2c27b9982f2d57d092cc.zip | |
feat: key usage panel — display usage data for Claude, OpenCode, and Copilot
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
| -rw-r--r-- | packages/api/src/routes/models.ts | 121 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/credentials/copilot.ts | 64 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 29 | ||||
| -rw-r--r-- | packages/core/src/credentials/opencode.ts | 129 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 282 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 32 |
8 files changed, 646 insertions, 22 deletions
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<ClaudeUsageReport const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => { if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined; const b = bucket as Record<string, unknown>; - 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<CopilotUsageReport | null> { + const url = "https://api.github.com/copilot_internal/user"; + const headers: Record<string, string> = { + 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<string, unknown>; + 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<string, unknown> | undefined; + const pi = qs?.premium_interactions as Record<string, unknown> | 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<OpencodeUsageReport | null> { + 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[\"<wsId>\"] + // 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 @@ +<script lang="ts"> + import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js"; + + const { + keys = [], + apiBase = "", + }: { + keys?: KeyInfo[]; + apiBase?: string; + } = $props(); + + let selectedKeyId = $state<string | null>(null); + let usageData = $state<KeyUsageData | null>(null); + let loading = $state(false); + let error = $state<string | null>(null); + + // Set default key when keys load asynchronously + $effect(() => { + if (selectedKeyId === null && keys.length > 0) { + selectedKeyId = keys[0]?.id ?? null; + } + }); + + async function fetchUsage(keyId: string) { + loading = true; + error = null; + usageData = null; + try { + const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(keyId)}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error ?? `HTTP ${res.status}: ${res.statusText}`); + } + usageData = await res.json(); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to fetch usage data"; + } finally { + loading = false; + } + } + + $effect(() => { + if (selectedKeyId) { + fetchUsage(selectedKeyId); + } + }); + + function progressClass(utilization: number): string { + if (utilization > 0.8) return "progress-error"; + if (utilization >= 0.5) return "progress-warning"; + return "progress-success"; + } + + function formatDate(ts: number): string { + return new Date(ts).toLocaleString(); + } + + function hasBucketData(bucket: UsageBucket | undefined): boolean { + return bucket !== undefined && bucket.utilization !== undefined; + } + + const hasAnyData = $derived.by(() => { + if (!usageData) return false; + if (usageData.provider === "anthropic") { + return hasBucketData(usageData.fiveHour) || hasBucketData(usageData.sevenDay); + } + if (usageData.provider === "opencode-go") { + // OpenCode has no API data but we always have something to show + return true; + } + if (usageData.provider === "github-copilot") { + return usageData.percentUsed !== undefined || usageData.tokensConsumed !== undefined; + } + return false; + }); +</script> + +<div class="flex flex-col gap-3"> + <!-- Key selector --> + {#if keys.length > 0} + <select + class="select select-bordered select-sm w-full" + bind:value={selectedKeyId} + > + {#each keys as key (key.id)} + <option value={key.id}>{key.id} ({key.provider})</option> + {/each} + </select> + {:else} + <p class="text-xs text-base-content/50">No keys available.</p> + {/if} + + <!-- Loading --> + {#if loading} + <div class="flex items-center gap-2 py-2"> + <span class="loading loading-spinner loading-sm"></span> + <span class="text-xs text-base-content/50">Loading usage data...</span> + </div> + {/if} + + <!-- Error --> + {#if error} + <div role="alert" class="alert alert-error alert-soft py-2 px-3 text-xs"> + {error} + </div> + {/if} + + <!-- Usage data --> + {#if !loading && usageData} + {#if !hasAnyData} + <p class="text-xs text-base-content/50">No usage data available for this key.</p> + + {:else if usageData.provider === "anthropic"} + <div class="flex flex-col gap-2"> + <p class="text-xs text-base-content/50 uppercase tracking-wide">Claude Usage</p> + + {#if hasBucketData(usageData.fiveHour)} + {@const bucket = usageData.fiveHour!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} + <div class="flex flex-col gap-0.5"> + <p class="text-xs text-base-content/50">5-Hour Window</p> + <progress + class="progress w-full {progressClass(util)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono">{pct}%</span> + {#if bucket.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {/if} + </div> + </div> + {/if} + + {#if hasBucketData(usageData.sevenDay)} + {@const bucket = usageData.sevenDay!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} + <div class="flex flex-col gap-0.5"> + <p class="text-xs text-base-content/50">Weekly (7-Day)</p> + <progress + class="progress w-full {progressClass(util)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono">{pct}%</span> + {#if bucket.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {/if} + </div> + </div> + {/if} + </div> + + {:else if usageData.provider === "opencode-go"} + <div class="flex flex-col gap-2"> + <p class="text-xs text-base-content/50 uppercase tracking-wide">OpenCode Usage</p> + + {#if usageData.unavailable} + <p class="text-xs text-base-content/70"> + OpenCode does not expose usage data via API. + </p> + {#if usageData.limits} + <div class="flex flex-col gap-1"> + <p class="text-xs text-base-content/50">Rate Limits</p> + <ul class="text-xs text-base-content/70 list-disc pl-4 flex flex-col gap-0.5"> + {#if usageData.limits.fiveHour} + <li>5-Hour: {usageData.limits.fiveHour}</li> + {/if} + {#if usageData.limits.weekly} + <li>Weekly: {usageData.limits.weekly}</li> + {/if} + {#if usageData.limits.monthly} + <li>Monthly: {usageData.limits.monthly}</li> + {/if} + </ul> + </div> + {/if} + {#if usageData.consoleUrl} + <a + href={usageData.consoleUrl} + target="_blank" + rel="noopener noreferrer" + class="link link-primary text-xs" + > + View usage in console + </a> + {/if} + {:else} + <!-- If API data ever becomes available, render buckets --> + {#if hasBucketData(usageData.fiveHour)} + {@const bucket = usageData.fiveHour!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} + <div class="flex flex-col gap-0.5"> + <p class="text-xs text-base-content/50">5-Hour Window</p> + <progress + class="progress w-full {progressClass(util)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono">{pct}%</span> + {#if bucket.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {/if} + </div> + </div> + {/if} + + {#if hasBucketData(usageData.weekly)} + {@const bucket = usageData.weekly!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} + <div class="flex flex-col gap-0.5"> + <p class="text-xs text-base-content/50">Weekly</p> + <progress + class="progress w-full {progressClass(util)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono">{pct}%</span> + {#if bucket.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {/if} + </div> + </div> + {/if} + + {#if hasBucketData(usageData.monthly)} + {@const bucket = usageData.monthly!} + {@const util = bucket.utilization ?? 0} + {@const pct = Math.round(util * 100)} + <div class="flex flex-col gap-0.5"> + <p class="text-xs text-base-content/50">Monthly</p> + <progress + class="progress w-full {progressClass(util)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + <span class="text-xs font-mono">{pct}%</span> + {#if bucket.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {/if} + </div> + </div> + {/if} + {/if} + </div> + + {:else if usageData.provider === "github-copilot"} + {@const pct = Math.round(usageData.percentUsed ?? 0)} + <div class="flex flex-col gap-2"> + <p class="text-xs text-base-content/50 uppercase tracking-wide">Copilot Usage</p> + <div class="flex flex-col gap-0.5"> + <progress + class="progress w-full {progressClass(pct / 100)}" + value={pct} + max="100" + ></progress> + <div class="flex items-center justify-between"> + {#if usageData.tokensConsumed !== undefined && usageData.tokensRemaining !== undefined} + <span class="text-xs font-mono"> + {usageData.tokensConsumed.toLocaleString()} / {(usageData.tokensConsumed + usageData.tokensRemaining).toLocaleString()} ({pct}%) + </span> + {:else} + <span class="text-xs font-mono">{pct}%</span> + {/if} + {#if usageData.resetAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(usageData.resetAt)}</span> + {/if} + </div> + </div> + </div> + {/if} + {/if} +</div> 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"]; </script> <div class="bg-base-200 rounded-lg p-3"> @@ -38,7 +39,9 @@ </select> <div class="mt-2"> - {#if selected === "Model Status"} + {#if selected === "Key Usage"} + <KeyUsage {keys} {apiBase} /> + {:else if selected === "Model Status"} <ModelStatus {models} {keys} {tags} /> {:else if selected === "Tasks"} <TaskListPanel {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; |
