summaryrefslogtreecommitdiffhomepage
path: root/packages/api
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/api
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/api')
-rw-r--r--packages/api/src/routes/models.ts121
1 files changed, 112 insertions, 9 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);
+ }
+});