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
|
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<KeyDefinition> & { id: string; provider: string },
overrides: Partial<Omit<KeyState, "definition">> = {},
): 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<ClaudeUsageResult | null>;
opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
}) {
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.");
});
});
|