1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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<ClaudeUsageResult | null>;
/**
* Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
* local cache). Defaults to `fetchOpencodeUsage`.
*/
fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
}
/** 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<ClaudeUsageResult | null>,
fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
): Promise<KeyUsageEntry> {
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<string, unknown>): Promise<string> => {
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());
},
};
}
|