summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
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 /packages/core/src
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
Diffstat (limited to 'packages/core/src')
-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
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;
+ }
+}