From bb5ce098a99e4ea8f36c6a725290d5858c36460f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 22:50:00 +0900 Subject: feat(tools): add key_usage tool reporting API-key usage levels Adds an agent-callable `key_usage` tool that reports current usage for configured API keys so the agent can pick a key with headroom, warn before hitting a rate limit, and diagnose exhausted-key failures. Per key it reports: provider, active/exhausted status (with last error + when it was exhausted), remaining rate-limit headroom and reset timestamp per window (5-hour, weekly, and monthly where the provider exposes it), and whether the figures are live or served from cache (with the cache's last-fetched-from-source time). Supports anthropic and opencode-go keys (live with cache fallback for anthropic; live scrape for opencode-go). Optional `key_id` reports one key; omitted reports all. Hard permission gate `perm_key_usage` (default off): when disabled the tool is completely removed from the toolset/context. Registered in both the parent permission-gated path and the child whitelist path, advertised in the system prompt (TOOL_DESCRIPTIONS), grantable to subagents via the summon enum, and exposed as a frontend tool-permission checkbox. To report data freshness, claude.ts gains `getAccountUsageWithSource` + `ClaudeUsageResult` (live vs cache + cachedAt from usage_cache.cached_at); the existing `getAccountUsage` now delegates to it, preserving behavior. Tests: core key-usage tool suite (windows, %-conversion, freshness, exhausted status, unsupported/unavailable, filtering) + agent-manager perm-gate test. --- packages/api/src/agent-manager.ts | 25 +- packages/api/tests/agent-manager.test.ts | 30 ++ packages/core/src/credentials/claude.ts | 69 ++++- packages/core/src/credentials/index.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/tools/key-usage.ts | 322 +++++++++++++++++++++ packages/core/src/tools/summon.ts | 1 + packages/core/tests/tools/key-usage.test.ts | 317 ++++++++++++++++++++ .../src/lib/components/ToolPermissions.svelte | 6 + packages/frontend/src/lib/settings.svelte.ts | 2 + 10 files changed, 763 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/tools/key-usage.ts create mode 100644 packages/core/tests/tools/key-usage.test.ts diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 2532efa..d1f81ed 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -13,6 +13,7 @@ import { clearSpillForTab, configToRuleset, createConfigWatcher, + createKeyUsageTool, createListFilesTool, createLspTool, createReadFileSliceTool, @@ -84,6 +85,8 @@ const TOOL_DESCRIPTIONS: Record = { search_code: "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.", todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.", + key_usage: + "Report current usage levels for configured API keys: provider, active/exhausted status, remaining rate-limit headroom and reset times per window (5-hour, weekly, monthly where available), and whether the figures are live or cached. Pass key_id for one key; omit to report all. Supported for anthropic and opencode-go keys.", summon: "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.", retrieve: @@ -515,10 +518,11 @@ export class AgentManager { const permReadTab = getSetting("perm_read_tab") === "allow"; const permWebSearch = getSetting("perm_web_search") === "allow"; const permSearchCode = getSetting("perm_search_code") === "allow"; + const permKeyUsage = getSetting("perm_key_usage") === "allow"; const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow"; const permLsp = getSetting("perm_lsp") === "allow"; const sysPrompt = getSetting("system_prompt") ?? ""; - const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permLsp}:${sysPrompt}`; + const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permKeyUsage}:${permLsp}:${sysPrompt}`; // If the override differs or permissions changed, invalidate the cached agent if ( @@ -610,6 +614,9 @@ export class AgentManager { if (allowed.has("web_search")) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + if (allowed.has("key_usage")) { + toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() }); + } if (allowed.has("lsp") && lspServers.length > 0) { toolEntries.push({ name: "lsp", @@ -715,6 +722,9 @@ export class AgentManager { if (permWebSearch) { toolEntries.push({ name: "web_search", tool: createWebSearchTool() }); } + if (permKeyUsage) { + toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() }); + } // The `lsp` tool exposes diagnostics + navigation on demand. It is // gated by `perm_lsp` AND requires at least one server configured // in the working directory's `dispatch.toml`. @@ -1404,6 +1414,19 @@ export class AgentManager { // running→queue / idle→new-turn routing that `POST /chat` uses (see // `deliverMessage`), so an agent message behaves identically to a user one. + /** + * Build the `key_usage` tool, wired to the live model registry (key states) + * and the discovered Claude accounts. The tool fetches usage live with a + * cache fallback (anthropic) or a live scrape (opencode-go), reporting + * remaining headroom, reset times, and data freshness per key. + */ + private buildKeyUsageTool(): ReturnType { + return createKeyUsageTool({ + listKeys: () => this.modelRegistry?.getKeys() ?? [], + listClaudeAccounts: () => this.claudeAccounts, + }); + } + /** * Build the `send_to_tab` + `read_tab` tool entries for `tabId`. Shared by * both tool-construction paths (child whitelist + permission-gated parent). diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index dbbcc65..788106e 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -472,6 +472,14 @@ vi.mock("@dispatch/core", () => ({ execute: async () => "mock", }; }, + createKeyUsageTool(_callbacks: unknown) { + return { + name: "key_usage", + description: "key usage", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, createSearchCodeTool(_wd: string) { return { name: "search_code", @@ -1568,6 +1576,28 @@ describe("AgentManager", () => { }); }); + describe("key_usage permission gate", () => { + // The key_usage tool is conditionally useful, so it must be COMPLETELY + // absent from the toolset (and thus the model's context) unless + // perm_key_usage is explicitly allowed. + async function toolsForPerms(tabId: string, perms: Record): Promise { + for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v); + const manager = new AgentManager(); + await manager.processMessage(tabId, "go"); + return constructedAgents.at(-1)?.toolNames ?? []; + } + + it("registers key_usage when perm_key_usage is allowed", async () => { + const tools = await toolsForPerms("tab-key-usage-on", { perm_key_usage: "allow" }); + expect(tools).toContain("key_usage"); + }); + + it("omits key_usage when perm_key_usage is not allowed", async () => { + const tools = await toolsForPerms("tab-key-usage-off", {}); + expect(tools).not.toContain("key_usage"); + }); + }); + // Regression: granted tab-messaging tools must also be ADVERTISED in the // agent's system prompt. The tools were registered in the API tool payload // but `buildSystemPrompt` filtered its "You have access to the following diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 7818222..050a0fc 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -441,6 +441,22 @@ export interface ClaudeUsageReport { orgId?: string; } +/** + * A usage report paired with provenance: whether it came back from a fresh + * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served + * from the local `usage_cache` table after a failed/skipped live fetch. + * + * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when + * that cached payload was last fetched FROM the source (the `usage_cache.cached_at` + * column). `source: "live"` omits `cachedAt` (the data is current as of now). + */ +export interface ClaudeUsageResult { + report: ClaudeUsageReport; + source: "live" | "cache"; + /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */ + cachedAt?: number; +} + // ─── Well-known Anthropic models ────────────────────────────── /** @@ -602,14 +618,23 @@ async function fetchClaudeUsage(accessToken: string): Promise { +/** + * Fetch an account's usage report along with its provenance (live vs cache). + * + * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint; + * on success the fresh report is cached and returned as `source: "live"`. If + * credentials cannot be refreshed OR the live fetch returns nothing, fall back + * to the local `usage_cache` row and return it as `source: "cache"` with the + * `cachedAt` timestamp recording when that payload was last fetched from source. + * Returns `null` only when neither a live report nor a cached row is available. + */ +export async function getAccountUsageWithSource( + account: ClaudeAccount, +): Promise { const creds = await refreshAccountCredentialsAsync(account); - if (!creds) return getCachedUsage(account.id); - const report = await fetchClaudeUsage(creds.accessToken); - if (report) { - setCachedUsage(account.id, "anthropic", report); - return report; + if (creds) { + const report = await fetchClaudeUsage(creds.accessToken); + if (report) { + setCachedUsage(account.id, "anthropic", report); + return { report, source: "live" }; + } } - return getCachedUsage(account.id); + const cached = getCachedUsageWithMeta(account.id); + if (cached) { + return { report: cached.report, source: "cache", cachedAt: cached.cachedAt }; + } + return null; +} + +export async function getAccountUsage(account: ClaudeAccount): Promise { + const result = await getAccountUsageWithSource(account); + return result?.report ?? null; } diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 5221dc6..131f035 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -15,9 +15,11 @@ export { type ClaudeProfile, type ClaudeUsageBucket, type ClaudeUsageReport, + type ClaudeUsageResult, discoverClaudeAccounts, fetchAnthropicModels, getAccountUsage, + getAccountUsageWithSource, getAnthropicBetas, getAnthropicHeaders, getClaudeAccountsFromDB, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08b426f..79704e7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -99,6 +99,7 @@ export { } from "./skills/index.js"; export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; // Tools +export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js"; export { createListFilesTool } from "./tools/list-files.js"; export { createLspTool, type LspToolContext } from "./tools/lsp.js"; export { createReadFileTool } from "./tools/read-file.js"; diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts new file mode 100644 index 0000000..0655ad7 --- /dev/null +++ b/packages/core/src/tools/key-usage.ts @@ -0,0 +1,322 @@ +import { z } from "zod"; +import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js"; +import { getAccountUsageWithSource } from "../credentials/claude.js"; +import type { OpencodeUsageReport } from "../credentials/opencode.js"; +import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js"; +import type { KeyState, ToolDefinition } from "../types/index.js"; + +/** + * Collaborators the `key_usage` tool needs from the API layer (which owns the + * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*` + * hooks default to the real credential fetchers but are injectable so tests can + * exercise the tool without network or DB access. + */ +export interface KeyUsageCallbacks { + /** Current key states from the model registry (definition + active/exhausted status). */ + listKeys(): KeyState[]; + /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */ + listClaudeAccounts(): ClaudeAccount[]; + /** + * Fetch an anthropic account's usage with provenance (live vs cache). + * Defaults to `getAccountUsageWithSource`. + */ + fetchAnthropicUsage?: (account: ClaudeAccount) => Promise; + /** + * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no + * local cache). Defaults to `fetchOpencodeUsage`. + */ + fetchOpencodeUsage?: (keyId: string) => Promise; +} + +/** A single normalized usage window (5-hour / week / month). */ +interface UsageWindow { + label: string; + /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */ + remainingPercent?: number; + /** Epoch-ms the window resets. Omitted when the source gives no reset time. */ + resetsAt?: number; +} + +/** Fully normalized per-key usage, ready for rendering. */ +interface KeyUsageEntry { + keyId: string; + provider: string; + status: "active" | "exhausted"; + lastError?: string; + exhaustedAt?: number; + /** Provenance of the usage figures: a fresh live fetch or a cached payload. */ + dataSource?: "live" | "cache"; + /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */ + cachedAt?: number; + windows: UsageWindow[]; + /** Set when no usage figures could be obtained for an otherwise-supported key. */ + unavailableReason?: string; + /** Set when the provider has no usage-reporting support. */ + unsupported?: boolean; +} + +function clampPercent(value: number): number { + if (value < 0) return 0; + if (value > 100) return 100; + return value; +} + +/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */ +function toWindow( + label: string, + bucket?: { utilization?: number; resetsAt?: number }, +): UsageWindow | null { + if (!bucket) return null; + const hasUtil = typeof bucket.utilization === "number"; + const hasReset = typeof bucket.resetsAt === "number"; + if (!hasUtil && !hasReset) return null; + return { + label, + ...(hasUtil + ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) } + : {}), + ...(hasReset ? { resetsAt: bucket.resetsAt } : {}), + }; +} + +function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] { + const windows: UsageWindow[] = []; + const fiveHour = toWindow("5-hour", report.fiveHour); + if (fiveHour) windows.push(fiveHour); + const week = toWindow("week", report.sevenDay); + if (week) windows.push(week); + return windows; +} + +function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] { + const windows: UsageWindow[] = []; + const fiveHour = toWindow("5-hour", report.fiveHour); + if (fiveHour) windows.push(fiveHour); + const week = toWindow("week", report.weekly); + if (week) windows.push(week); + const month = toWindow("month", report.monthly); + if (month) windows.push(month); + return windows; +} + +/** + * Resolve which Claude account backs an `anthropic` key. Matches by key id or by + * the account's source file (the key's `credentials_file`), falling back to the + * first available account — mirrors the existing `/models/key-usage` route. + */ +function matchAnthropicAccount( + accounts: ClaudeAccount[], + keyId: string, + credFile?: string, +): ClaudeAccount | undefined { + const matched = accounts.find( + (a) => a.id === keyId || (credFile != null && a.source === credFile), + ); + return matched ?? accounts[0]; +} + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */ +function formatDuration(ms: number): string { + const totalSec = Math.round(Math.abs(ms) / 1000); + const days = Math.floor(totalSec / 86400); + const hours = Math.floor((totalSec % 86400) / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); + return parts.join(" "); +} + +function formatRelative(targetMs: number, nowMs: number): string { + const delta = targetMs - nowMs; + return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`; +} + +function formatWindow(window: UsageWindow, now: number): string { + const parts: string[] = []; + if (typeof window.remainingPercent === "number") { + parts.push(`${window.remainingPercent}% remaining`); + } + if (typeof window.resetsAt === "number") { + parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`); + } + return `${window.label}: ${parts.join(", ")}`; +} + +/** + * Render normalized usage entries into an AI-friendly text block. Pure — `now` + * is injected so relative timestamps are deterministic under test. + */ +export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string { + if (entries.length === 0) return "No API keys matched."; + + const lines: string[] = []; + lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`); + + for (const entry of entries) { + lines.push(""); + lines.push(`[${entry.keyId}] provider: ${entry.provider}`); + + if (entry.status === "exhausted") { + const since = + typeof entry.exhaustedAt === "number" + ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})` + : ""; + lines.push(`status: EXHAUSTED${since}`); + if (entry.lastError) lines.push(`last error: ${entry.lastError}`); + } else { + lines.push("status: active"); + } + + if (entry.unsupported) { + lines.push( + `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`, + ); + continue; + } + + if (entry.dataSource === "live") { + lines.push("data: live (fetched just now)"); + } else if (entry.dataSource === "cache") { + lines.push( + typeof entry.cachedAt === "number" + ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})` + : "data: cached (source timestamp unknown)", + ); + } + + for (const window of entry.windows) { + lines.push(formatWindow(window, now)); + } + + if (entry.unavailableReason) { + lines.push(`usage: unavailable — ${entry.unavailableReason}`); + } + } + + return lines.join("\n"); +} + +async function buildEntry( + key: KeyState, + accounts: ClaudeAccount[], + fetchAnthropic: (account: ClaudeAccount) => Promise, + fetchOpencode: (keyId: string) => Promise, +): Promise { + const def = key.definition; + const entry: KeyUsageEntry = { + keyId: def.id, + provider: def.provider, + status: key.status, + windows: [], + ...(key.lastError ? { lastError: key.lastError } : {}), + ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}), + }; + + if (def.provider === "anthropic") { + const account = matchAnthropicAccount(accounts, def.id, def.credentials_file); + if (!account) { + entry.unavailableReason = "no Claude account credentials available for this key"; + return entry; + } + let result: ClaudeUsageResult | null = null; + try { + result = await fetchAnthropic(account); + } catch { + result = null; + } + if (!result) { + entry.unavailableReason = "no live usage data and no cached usage available"; + return entry; + } + entry.dataSource = result.source; + if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt; + entry.windows = anthropicWindows(result.report); + if (entry.windows.length === 0) { + entry.unavailableReason = "usage endpoint returned no window data"; + } + return entry; + } + + if (def.provider === "opencode-go") { + let report: OpencodeUsageReport | null = null; + try { + report = await fetchOpencode(def.id); + } catch { + report = null; + } + if (!report) { + entry.unavailableReason = + "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)"; + return entry; + } + entry.dataSource = "live"; + entry.windows = opencodeWindows(report); + if (entry.windows.length === 0) { + entry.unavailableReason = "usage source returned no window data"; + } + return entry; + } + + entry.unsupported = true; + return entry; +} + +export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition { + const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource; + const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage; + + return { + name: "key_usage", + description: [ + "Report current usage levels for configured API keys so you can pick a key with", + "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.", + "", + "For each key it returns: provider, active/exhausted status (with the last error when", + "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where", + "the provider exposes it), each window's reset timestamp, and whether the figures are", + "live or served from cache (with the cache's last-fetched time).", + "", + "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is", + "supported for anthropic and opencode-go keys.", + ].join("\n"), + parameters: z.object({ + key_id: z + .string() + .optional() + .describe( + 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.', + ), + }), + execute: async (args: Record): Promise => { + const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined; + + const allKeys = callbacks.listKeys(); + if (allKeys.length === 0) { + return "No API keys are configured."; + } + + let keys = allKeys; + if (requestedKeyId) { + keys = allKeys.filter((k) => k.definition.id === requestedKeyId); + if (keys.length === 0) { + const available = allKeys.map((k) => k.definition.id).join(", "); + return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`; + } + } + + const accounts = callbacks.listClaudeAccounts(); + const entries: KeyUsageEntry[] = []; + for (const key of keys) { + entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode)); + } + + return formatKeyUsage(entries, Date.now()); + }, + }; +} diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts index b941152..2a076e6 100644 --- a/packages/core/src/tools/summon.ts +++ b/packages/core/src/tools/summon.ts @@ -287,6 +287,7 @@ export function createSummonTool( "write_file", "run_shell", "search_code", + "key_usage", "todo", "summon", "retrieve", diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts new file mode 100644 index 0000000..643e30e --- /dev/null +++ b/packages/core/tests/tools/key-usage.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi } from "vitest"; + +// The tool imports `getAccountUsageWithSource` from `claude.ts`, which +// transitively imports `db/index.js` (top-level `import { Database } from +// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject +// stub fetchers and never hit the real fetchers/DB, so stubbing the db module +// is enough to let the import chain resolve. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => { + throw new Error("db not available in this test"); + }), +})); + +import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js"; +import type { OpencodeUsageReport } from "../../src/credentials/opencode.js"; +import { + createKeyUsageTool, + formatKeyUsage, + type KeyUsageCallbacks, +} from "../../src/tools/key-usage.js"; +import type { KeyDefinition, KeyState } from "../../src/types/index.js"; + +// ─── Builders ───────────────────────────────────────────────── + +function keyState( + def: Partial & { id: string; provider: string }, + overrides: Partial> = {}, +): KeyState { + return { + definition: { base_url: "https://example.test", ...def }, + status: "active", + ...overrides, + }; +} + +function account(id: string, source = `/creds/${id}.json`): ClaudeAccount { + return { + id, + label: id, + source, + credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 }, + }; +} + +/** Build the tool with explicit stub fetchers — no network, no DB. */ +function buildTool(opts: { + keys: KeyState[]; + accounts?: ClaudeAccount[]; + anthropic?: (a: ClaudeAccount) => Promise; + opencode?: (keyId: string) => Promise; +}) { + const callbacks: KeyUsageCallbacks = { + listKeys: () => opts.keys, + listClaudeAccounts: () => opts.accounts ?? [], + fetchAnthropicUsage: opts.anthropic ?? (async () => null), + fetchOpencodeUsage: opts.opencode ?? (async () => null), + }; + return createKeyUsageTool(callbacks); +} + +const HOUR = 3_600_000; + +describe("key_usage tool", () => { + it("reports all keys when no key_id is given", async () => { + const reset5h = Date.now() + 2 * HOUR; + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + accounts: [account("claude-max", "/creds/max.json")], + anthropic: async () => ({ + source: "live", + report: { + fiveHour: { utilization: 0.25, resetsAt: reset5h }, + sevenDay: { utilization: 0.6 }, + }, + }), + opencode: async () => ({ + fiveHour: { utilization: 0.1 }, + weekly: { utilization: 0.4 }, + monthly: { utilization: 0.7 }, + }), + }); + + const out = await tool.execute({}); + + // Both keys present with providers. + expect(out).toContain("[claude-max] provider: anthropic"); + expect(out).toContain("[opencode-1] provider: opencode-go"); + // Remaining = (1 - utilization) * 100. + expect(out).toContain("5-hour: 75% remaining"); + expect(out).toContain("week: 40% remaining"); + expect(out).toContain("5-hour: 90% remaining"); + expect(out).toContain("week: 60% remaining"); + expect(out).toContain("month: 30% remaining"); + expect(out).toContain("data: live (fetched just now)"); + }); + + it("filters to a single key when key_id is given and does not fetch others", async () => { + const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } })); + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "live", + report: { fiveHour: { utilization: 0.2 } }, + }), + opencode: opencodeFetch, + }); + + const out = await tool.execute({ key_id: "claude-max" }); + + expect(out).toContain("[claude-max] provider: anthropic"); + expect(out).not.toContain("opencode-1"); + expect(opencodeFetch).not.toHaveBeenCalled(); + }); + + it("returns a helpful error for an unknown key_id", async () => { + const tool = buildTool({ + keys: [ + keyState({ id: "claude-max", provider: "anthropic" }), + keyState({ id: "opencode-1", provider: "opencode-go" }), + ], + }); + + const out = await tool.execute({ key_id: "nope" }); + + expect(out).toContain('no key found with id "nope"'); + expect(out).toContain("claude-max"); + expect(out).toContain("opencode-1"); + }); + + it("reports cached data with the source's last-fetched timestamp", async () => { + const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5); + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "cache", + cachedAt, + report: { fiveHour: { utilization: 0.5 } }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z"); + expect(out).toContain("5-hour: 50% remaining"); + }); + + it("omits the month window for anthropic (no monthly bucket)", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => ({ + source: "live", + report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("5-hour:"); + expect(out).toContain("week:"); + expect(out).not.toContain("month:"); + }); + + it("includes the month window for opencode-go", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => ({ + fiveHour: { utilization: 0.1 }, + weekly: { utilization: 0.2 }, + monthly: { utilization: 0.3 }, + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("month: 70% remaining"); + }); + + it("surfaces exhausted status with the last error", async () => { + const exhaustedAt = Date.now() - HOUR; + const tool = buildTool({ + keys: [ + keyState( + { id: "opencode-1", provider: "opencode-go" }, + { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt }, + ), + ], + opencode: async () => null, + }); + + const out = await tool.execute({}); + + expect(out).toContain("status: EXHAUSTED"); + expect(out).toContain("last error: 429 rate limit exceeded"); + }); + + it("flags providers without usage support", async () => { + const tool = buildTool({ + keys: [keyState({ id: "gem", provider: "google" })], + }); + + const out = await tool.execute({}); + + expect(out).toContain("[gem] provider: google"); + expect(out).toContain("not supported"); + }); + + it("reports unavailable when a supported provider returns no usage", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [account("claude-max")], + anthropic: async () => null, + }); + + const out = await tool.execute({}); + + expect(out).toContain("usage: unavailable"); + expect(out).toContain("no cached usage"); + }); + + it("reports unavailable for anthropic keys with no account credentials", async () => { + const tool = buildTool({ + keys: [keyState({ id: "claude-max", provider: "anthropic" })], + accounts: [], + }); + + const out = await tool.execute({}); + + expect(out).toContain("no Claude account credentials available"); + }); + + it("treats a fetcher that throws as unavailable (does not crash)", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => { + throw new Error("network down"); + }, + }); + + const out = await tool.execute({}); + + expect(out).toContain("usage: unavailable"); + }); + + it("reports when no keys are configured at all", async () => { + const tool = buildTool({ keys: [] }); + const out = await tool.execute({}); + expect(out).toBe("No API keys are configured."); + }); + + it("clamps out-of-range utilization to 0–100%", async () => { + const tool = buildTool({ + keys: [keyState({ id: "opencode-1", provider: "opencode-go" })], + opencode: async () => ({ + fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining + weekly: { utilization: -0.5 }, // negative → 100% remaining + }), + }); + + const out = await tool.execute({}); + + expect(out).toContain("5-hour: 0% remaining"); + expect(out).toContain("week: 100% remaining"); + }); +}); + +describe("formatKeyUsage (pure)", () => { + const now = Date.UTC(2025, 5, 1, 12, 0, 0); + + it("formats reset timestamps with ISO + relative time", () => { + const out = formatKeyUsage( + [ + { + keyId: "claude-max", + provider: "anthropic", + status: "active", + dataSource: "live", + windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }], + }, + ], + now, + ); + + expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)"); + }); + + it("renders a past reset/exhaustion time as 'ago'", () => { + const out = formatKeyUsage( + [ + { + keyId: "opencode-1", + provider: "opencode-go", + status: "exhausted", + exhaustedAt: now - 2 * HOUR, + lastError: "boom", + windows: [], + }, + ], + now, + ); + + expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)"); + expect(out).toContain("last error: boom"); + }); + + it("returns a friendly message when no entries match", () => { + expect(formatKeyUsage([], now)).toBe("No API keys matched."); + }); +}); diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte index 6b09a07..4298724 100644 --- a/packages/frontend/src/lib/components/ToolPermissions.svelte +++ b/packages/frontend/src/lib/components/ToolPermissions.svelte @@ -52,6 +52,12 @@ const toolPermissions: ToolPermission[] = [ label: "Search code", description: "Allow the AI to search the codebase with the cs ranked code-search engine", }, + { + id: "key_usage", + label: "Key usage", + description: + "Allow the AI to read current API-key usage levels, rate-limit headroom, and reset times", + }, { id: "lsp", label: "LSP queries", diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 0da4e45..1b93804 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -15,6 +15,7 @@ let toolPerms = $state>({ web_search: false, youtube_transcribe: false, search_code: false, + key_usage: false, lsp: false, }); let savedToolPerms = $state>({ @@ -29,6 +30,7 @@ let savedToolPerms = $state>({ web_search: false, youtube_transcribe: false, search_code: false, + key_usage: false, lsp: false, }); let skillChecks = $state>({}); -- cgit v1.2.3 From fee14e4509453da7f73efa1fdeb59d66133706ae Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 3 Jun 2026 00:45:12 +0900 Subject: feat(tabs): pulsing status dot to grab attention when agent needs the user Use DaisyUI's status-with-ping pattern on the tab status dot so it pings when the agent has stopped and is likely waiting on the user: - idle with incomplete (pending/in_progress) tasks remaining, or - stopped due to an error. Implements wishlist item #21. --- packages/frontend/src/lib/components/TabBar.svelte | 34 ++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index 354260c..824b86b 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -1,6 +1,7 @@