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 /packages/core/src | |
| 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
Diffstat (limited to 'packages/core/src')
| -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 |
4 files changed, 215 insertions, 11 deletions
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; + } +} |
