summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 22:32:41 +0900
committerAdam Malczewski <[email protected]>2026-05-20 22:32:41 +0900
commitc0c55ed7c03188f264ff2c27b9982f2d57d092cc (patch)
tree04b871b83e6e1481c8b24b59aa3acc0e15b44f02
parent8151447758e6826a578363758a755c6cebd1c05f (diff)
downloaddispatch-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.ts121
-rw-r--r--packages/core/src/credentials/claude.ts4
-rw-r--r--packages/core/src/credentials/copilot.ts64
-rw-r--r--packages/core/src/credentials/index.ts29
-rw-r--r--packages/core/src/credentials/opencode.ts129
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte282
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte7
-rw-r--r--packages/frontend/src/lib/types.ts32
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;