summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 22:44:42 +0900
committerAdam Malczewski <[email protected]>2026-05-20 22:44:42 +0900
commitba68da660c9a02b90ff2134152abe650f004867e (patch)
tree832dd87a2e211596b3ef5f3f6ed11774ff881fb7
parentc0c55ed7c03188f264ff2c27b9982f2d57d092cc (diff)
downloaddispatch-ba68da660c9a02b90ff2134152abe650f004867e.tar.gz
dispatch-ba68da660c9a02b90ff2134152abe650f004867e.zip
feat: key usage shows all keys at once, removes dropdown, multi-account Claude
- /key-usage route now returns all Claude accounts (not just first) - Added ClaudeAccountUsage type for per-account data - Frontend shows all keys stacked in scrollable cards - Each Claude account rendered separately with label + subscription badge - Removed key selector dropdown
-rw-r--r--packages/api/src/routes/models.ts33
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte411
-rw-r--r--packages/frontend/src/lib/types.ts10
3 files changed, 212 insertions, 242 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index b79a45d..906ecc4 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -258,24 +258,29 @@ modelsRoutes.get("/key-usage", async (c) => {
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) {
+ if (accounts.length === 0) {
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);
- }
+ // Fetch usage for all discovered accounts
+ const accountResults = await Promise.all(
+ accounts.map(async (acct) => {
+ const report = await getAccountUsage(acct);
+ return {
+ label: acct.label,
+ source: acct.source,
+ subscriptionType: acct.credentials.subscriptionType,
+ fiveHour: report?.fiveHour,
+ sevenDay: report?.sevenDay,
+ error: report ? undefined : "failed to fetch",
+ };
+ }),
+ );
return c.json({
provider: "anthropic",
- fiveHour: report.fiveHour,
- sevenDay: report.sevenDay,
+ accounts: accountResults,
+ // Legacy single-account fields (first account)
+ fiveHour: accountResults[0]?.fiveHour,
+ sevenDay: accountResults[0]?.sevenDay,
});
} else if (provider === "opencode-go") {
// Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index 313c183..7f0453e 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -9,40 +9,57 @@
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);
+ interface KeyUsageEntry {
+ keyId: string;
+ provider: string;
+ data: KeyUsageData | null;
+ error: string | null;
+ }
- // Set default key when keys load asynchronously
- $effect(() => {
- if (selectedKeyId === null && keys.length > 0) {
- selectedKeyId = keys[0]?.id ?? null;
- }
- });
+ let entries = $state<KeyUsageEntry[]>([]);
+ let loading = $state(true);
- async function fetchUsage(keyId: string) {
+ async function fetchAll() {
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}`);
+ const results: KeyUsageEntry[] = [];
+
+ for (const key of keys) {
+ try {
+ const res = await fetch(
+ `${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`,
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ results.push({
+ keyId: key.id,
+ provider: key.provider,
+ data: null,
+ error: data.error ?? `HTTP ${res.status}`,
+ });
+ } else {
+ results.push({
+ keyId: key.id,
+ provider: key.provider,
+ data: await res.json(),
+ error: null,
+ });
+ }
+ } catch (e) {
+ results.push({
+ keyId: key.id,
+ provider: key.provider,
+ data: null,
+ error: e instanceof Error ? e.message : "Failed to fetch",
+ });
}
- usageData = await res.json();
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch usage data";
- } finally {
- loading = false;
}
+
+ entries = results;
+ loading = false;
}
$effect(() => {
- if (selectedKeyId) {
- fetchUsage(selectedKeyId);
- }
+ fetchAll();
});
function progressClass(utilization: number): string {
@@ -58,225 +75,163 @@
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}
+ {#if keys.length === 0}
<p class="text-xs text-base-content/50">No keys available.</p>
- {/if}
-
- <!-- Loading -->
- {#if loading}
+ {:else 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}
+ <div class="flex flex-col gap-3 max-h-96 overflow-y-auto">
+ {#each entries as entry (entry.keyId)}
+ <div
+ class="bg-base-200 rounded-lg p-2"
+ >
+ <div class="flex items-center gap-1.5 mb-1.5">
+ <span class="text-xs font-semibold">{entry.keyId}</span>
+ <span class="badge badge-xs badge-ghost">{entry.provider}</span>
+ </div>
- {: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 entry.error}
+ <div role="alert" class="text-xs text-error/80">{entry.error}</div>
+
+ {:else if !entry.data}
+ <p class="text-xs text-base-content/50">No data.</p>
+
+ {:else if entry.data.provider === "anthropic"}
+ <!-- Render each Claude account -->
+ {#if entry.data.accounts}
+ {#each entry.data.accounts as acct (acct.source)}
+ <div class="flex flex-col gap-1 pl-1">
+ <div class="flex items-center gap-1">
+ <span class="text-xs font-medium">{acct.label}</span>
+ {#if acct.subscriptionType}
+ <span class="badge badge-xs">{acct.subscriptionType}</span>
+ {/if}
+ </div>
+
+ {#if acct.error}
+ <p class="text-xs text-error/70">{acct.error}</p>
+ {/if}
+
+ {#if hasBucketData(acct.fiveHour)}
+ {@const b = acct.fiveHour!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">5-Hour</span>
+ <span class="text-xs font-mono">{p}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+
+ {#if hasBucketData(acct.sevenDay)}
+ {@const b = acct.sevenDay!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Weekly</span>
+ <span class="text-xs font-mono">{p}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/each}
+ {/if}
- {#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>
+ {:else if entry.data.provider === "opencode-go"}
+ {#if entry.data.unavailable}
+ <p class="text-xs text-base-content/70">Check console for usage.</p>
+ {#if entry.data.consoleUrl}
+ <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs">
+ Open console
+ </a>
{/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>
+ {:else}
+ {#if hasBucketData(entry.data.fiveHour)}
+ {@const b = entry.data.fiveHour!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">5-Hour</span>
+ <span class="text-xs font-mono">{p}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
{/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(entry.data.weekly)}
+ {@const b = entry.data.weekly!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Weekly</span>
+ <span class="text-xs font-mono">{p}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ {#if hasBucketData(entry.data.monthly)}
+ {@const b = entry.data.monthly!}
+ {@const u = b.utilization ?? 0}
+ {@const p = Math.round(u * 100)}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Monthly</span>
+ <span class="text-xs font-mono">{p}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress>
+ {#if b.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
+ {/if}
+ </div>
+ {/if}
+ {/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>
+ {:else if entry.data.provider === "github-copilot"}
+ {@const p = Math.round(entry.data.percentUsed ?? 0)}
+ <div class="flex flex-col gap-0.5 pl-1">
<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}
+ <span class="text-xs text-base-content/50">
+ {#if entry.data.tokensConsumed !== undefined && entry.data.tokensRemaining !== undefined}
+ {entry.data.tokensConsumed.toLocaleString()} / {(entry.data.tokensConsumed + entry.data.tokensRemaining).toLocaleString()} tokens
+ {:else if entry.data.plan}
+ {entry.data.plan}
+ {:else}
+ Usage
+ {/if}
+ </span>
+ <span class="text-xs font-mono">{p}%</span>
</div>
+ <progress class="progress w-full h-2 {progressClass(p / 100)}" value={p} max="100"></progress>
+ {#if entry.data.resetAt}
+ <span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
+ {/if}
</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}
+ {/each}
+ </div>
{/if}
</div>
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index e7ff4ba..559742d 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -114,8 +114,18 @@ export interface UsageBucket {
resetsAt?: number;
}
+export interface ClaudeAccountUsage {
+ label: string;
+ source: string;
+ subscriptionType?: string;
+ fiveHour?: UsageBucket;
+ sevenDay?: UsageBucket;
+ error?: string;
+}
+
export interface ClaudeUsageData {
provider: "anthropic";
+ accounts?: ClaudeAccountUsage[];
fiveHour?: UsageBucket;
sevenDay?: UsageBucket;
}