opencode-anthropic-fix 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +24 -0
  2. package/index.mjs +155 -149
  3. package/lib/config.mjs +47 -25
  4. package/lib/mimicry/adapter-input.mjs +671 -0
  5. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  6. package/lib/mimicry/headers.mjs +71 -64
  7. package/lib/mimicry/request-body.mjs +23 -22
  8. package/lib/mimicry/system-prompt.mjs +54 -23
  9. package/lib/mimicry/wire-compat.mjs +305 -0
  10. package/package.json +3 -3
  11. package/lib/account-state.test.mjs +0 -109
  12. package/lib/accounts.test.mjs +0 -912
  13. package/lib/adaptive-thinking-models.test.mjs +0 -30
  14. package/lib/backoff.test.mjs +0 -411
  15. package/lib/cc-credentials.test.mjs +0 -391
  16. package/lib/config.test.mjs +0 -764
  17. package/lib/context-hint-persist.mjs +0 -68
  18. package/lib/diagnose.test.mjs +0 -122
  19. package/lib/env.test.mjs +0 -25
  20. package/lib/haiku-call.test.mjs +0 -72
  21. package/lib/message-transform.test.mjs +0 -173
  22. package/lib/mimicry/cache.test.mjs +0 -112
  23. package/lib/mimicry/headers.test.mjs +0 -50
  24. package/lib/mimicry/models.test.mjs +0 -132
  25. package/lib/mimicry/request-body.test.mjs +0 -34
  26. package/lib/mimicry/request-helpers.test.mjs +0 -155
  27. package/lib/mimicry/response-stream.test.mjs +0 -127
  28. package/lib/mimicry/system-prompt.test.mjs +0 -73
  29. package/lib/oauth.test.mjs +0 -343
  30. package/lib/redact.test.mjs +0 -154
  31. package/lib/refresh-lock.test.mjs +0 -131
  32. package/lib/request-headers.test.mjs +0 -164
  33. package/lib/retry/overload-loop.test.mjs +0 -107
  34. package/lib/rotation.test.mjs +0 -392
  35. package/lib/session-metrics.test.mjs +0 -67
  36. package/lib/storage.test.mjs +0 -696
  37. package/lib/token-economy/microcompact.test.mjs +0 -28
  38. package/lib/token-economy/transforms.test.mjs +0 -192
  39. package/lib/tuning.test.mjs +0 -89
@@ -1,912 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { AccountManager, RATE_LIMIT_KEY_FAST } from "./accounts.mjs";
3
- import { DEFAULT_CONFIG } from "./config.mjs";
4
-
5
- // Mock storage module — pass through pure helpers, mock I/O
6
- vi.mock("./storage.mjs", async (importOriginal) => {
7
- const actual = await importOriginal();
8
- return {
9
- ...actual,
10
- loadAccounts: vi.fn(),
11
- saveAccounts: vi.fn().mockResolvedValue(undefined),
12
- };
13
- });
14
-
15
- import { loadAccounts, saveAccounts } from "./storage.mjs";
16
-
17
- /** Build a stored account with sensible defaults; override any field. */
18
- function makeStoredAccount(overrides = {}) {
19
- return {
20
- refreshToken: "token1",
21
- addedAt: 1000,
22
- lastUsed: 0,
23
- enabled: true,
24
- rateLimitResetTimes: {},
25
- consecutiveFailures: 0,
26
- lastFailureTime: null,
27
- ...overrides,
28
- };
29
- }
30
-
31
- /** Build a stored accounts payload from an array of per-account overrides. */
32
- function makeAccountsData(overrides = [{}], extra = {}) {
33
- return {
34
- version: 1,
35
- accounts: overrides.map((o, i) =>
36
- makeStoredAccount({ refreshToken: `token${i + 1}`, addedAt: (i + 1) * 1000, ...o }),
37
- ),
38
- activeIndex: 0,
39
- ...extra,
40
- };
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // AccountManager.load
45
- // ---------------------------------------------------------------------------
46
-
47
- describe("AccountManager.load", () => {
48
- beforeEach(() => {
49
- vi.resetAllMocks();
50
- vi.useFakeTimers();
51
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
52
- });
53
-
54
- it("creates empty manager when no stored accounts and no fallback", async () => {
55
- loadAccounts.mockResolvedValue(null);
56
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
57
- expect(manager.getAccountCount()).toBe(0);
58
- expect(manager.getTotalAccountCount()).toBe(0);
59
- });
60
-
61
- it("bootstraps from auth fallback when no stored accounts", async () => {
62
- loadAccounts.mockResolvedValue(null);
63
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
64
- refresh: "refresh-token-1",
65
- access: "access-token-1",
66
- expires: Date.now() + 3600_000,
67
- });
68
- expect(manager.getAccountCount()).toBe(1);
69
- expect(manager.getTotalAccountCount()).toBe(1);
70
- expect(manager.getCurrentIndex()).toBe(0);
71
- });
72
-
73
- it("does not bootstrap from auth fallback when storage exists but is empty", async () => {
74
- loadAccounts.mockResolvedValue(makeAccountsData([], { activeIndex: 0 }));
75
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
76
- refresh: "refresh-token-1",
77
- access: "access-token-1",
78
- expires: Date.now() + 3600_000,
79
- });
80
- expect(manager.getAccountCount()).toBe(0);
81
- expect(manager.getTotalAccountCount()).toBe(0);
82
- expect(manager.getCurrentIndex()).toBe(-1);
83
- });
84
-
85
- it("loads stored accounts from disk", async () => {
86
- loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }, { lastUsed: 4000 }], { activeIndex: 1 }));
87
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
88
- expect(manager.getAccountCount()).toBe(2);
89
- expect(manager.getCurrentIndex()).toBe(1);
90
- });
91
-
92
- it("matches auth fallback to existing stored account", async () => {
93
- loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }]));
94
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
95
- refresh: "token1",
96
- access: "fresh-access",
97
- expires: Date.now() + 3600_000,
98
- });
99
- const snapshot = manager.getAccountsSnapshot();
100
- expect(snapshot[0].access).toBe("fresh-access");
101
- });
102
-
103
- it("does not let stale/partial fallback override fresher stored auth", async () => {
104
- loadAccounts.mockResolvedValue(
105
- makeAccountsData([
106
- {
107
- lastUsed: 2000,
108
- access: "disk-access",
109
- expires: Date.now() + 6 * 3600_000,
110
- token_updated_at: Date.now() + 6 * 3600_000,
111
- },
112
- ]),
113
- );
114
-
115
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
116
- refresh: "token1",
117
- access: "stale-fallback-access",
118
- expires: Date.now() - 60_000,
119
- });
120
-
121
- const snapshot = manager.getAccountsSnapshot();
122
- expect(snapshot[0].access).toBe("disk-access");
123
- expect(snapshot[0].expires).toBeGreaterThan(Date.now());
124
- });
125
-
126
- it("clamps activeIndex to valid range", async () => {
127
- loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
128
- const manager = await AccountManager.load(DEFAULT_CONFIG, null);
129
- expect(manager.getCurrentIndex()).toBe(0);
130
- });
131
- });
132
-
133
- // ---------------------------------------------------------------------------
134
- // Account management
135
- // ---------------------------------------------------------------------------
136
-
137
- describe("AccountManager account management", () => {
138
- /** @type {AccountManager} */
139
- let manager;
140
-
141
- beforeEach(async () => {
142
- vi.resetAllMocks();
143
- vi.useFakeTimers();
144
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
145
- loadAccounts.mockResolvedValue(null);
146
- manager = await AccountManager.load(DEFAULT_CONFIG, {
147
- refresh: "token1",
148
- access: "access1",
149
- expires: Date.now() + 3600_000,
150
- });
151
- });
152
-
153
- it("addAccount adds a new account", () => {
154
- const account = manager.addAccount("token2", "access2", Date.now() + 3600_000, "user@example.com");
155
- expect(account).not.toBeNull();
156
- expect(manager.getTotalAccountCount()).toBe(2);
157
- expect(account.email).toBe("user@example.com");
158
- });
159
-
160
- it("addAccount deduplicates by refresh token", () => {
161
- manager.addAccount("token1", "new-access", Date.now() + 7200_000);
162
- expect(manager.getTotalAccountCount()).toBe(1);
163
- const snapshot = manager.getAccountsSnapshot();
164
- expect(snapshot[0].access).toBe("new-access");
165
- });
166
-
167
- it("addAccount respects MAX_ACCOUNTS limit", () => {
168
- for (let i = 2; i <= 10; i++) {
169
- manager.addAccount(`token${i}`, `access${i}`, Date.now() + 3600_000);
170
- }
171
- expect(manager.getTotalAccountCount()).toBe(10);
172
- const result = manager.addAccount("token11", "access11", Date.now() + 3600_000);
173
- expect(result).toBeNull();
174
- expect(manager.getTotalAccountCount()).toBe(10);
175
- });
176
-
177
- it("removeAccount removes by index", () => {
178
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
179
- expect(manager.getTotalAccountCount()).toBe(2);
180
- const removed = manager.removeAccount(0);
181
- expect(removed).toBe(true);
182
- expect(manager.getTotalAccountCount()).toBe(1);
183
- const snapshot = manager.getAccountsSnapshot();
184
- expect(snapshot[0].refreshToken).toBe("token2");
185
- expect(snapshot[0].index).toBe(0); // Re-indexed
186
- });
187
-
188
- it("removeAccount returns false for invalid index", () => {
189
- expect(manager.removeAccount(-1)).toBe(false);
190
- expect(manager.removeAccount(99)).toBe(false);
191
- });
192
-
193
- it("removeAccount adjusts currentIndex", () => {
194
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
195
- // Select account 1
196
- manager.getCurrentAccount();
197
- manager.removeAccount(0);
198
- // currentIndex should be adjusted
199
- expect(manager.getCurrentIndex()).toBeLessThanOrEqual(0);
200
- });
201
-
202
- it("toggleAccount toggles enabled state", () => {
203
- const newState = manager.toggleAccount(0);
204
- expect(newState).toBe(false);
205
- expect(manager.getAccountCount()).toBe(0); // Disabled
206
- const restored = manager.toggleAccount(0);
207
- expect(restored).toBe(true);
208
- expect(manager.getAccountCount()).toBe(1);
209
- });
210
-
211
- it("toggleAccount returns false for invalid index", () => {
212
- expect(manager.toggleAccount(99)).toBe(false);
213
- });
214
-
215
- it("clearAll removes all accounts", () => {
216
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
217
- manager.clearAll();
218
- expect(manager.getTotalAccountCount()).toBe(0);
219
- expect(manager.getCurrentIndex()).toBe(-1);
220
- });
221
-
222
- it("getAccountsSnapshot returns copies", () => {
223
- const snapshot = manager.getAccountsSnapshot();
224
- snapshot[0].email = "modified";
225
- const snapshot2 = manager.getAccountsSnapshot();
226
- expect(snapshot2[0].email).not.toBe("modified");
227
- });
228
- });
229
-
230
- // ---------------------------------------------------------------------------
231
- // Account selection
232
- // ---------------------------------------------------------------------------
233
-
234
- describe("AccountManager account selection", () => {
235
- /** @type {AccountManager} */
236
- let manager;
237
-
238
- beforeEach(async () => {
239
- vi.resetAllMocks();
240
- vi.useFakeTimers();
241
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
242
- loadAccounts.mockResolvedValue(null);
243
- manager = await AccountManager.load(DEFAULT_CONFIG, {
244
- refresh: "token1",
245
- access: "access1",
246
- expires: Date.now() + 3600_000,
247
- });
248
- });
249
-
250
- it("getCurrentAccount returns an account", () => {
251
- const account = manager.getCurrentAccount();
252
- expect(account).not.toBeNull();
253
- expect(account.refreshToken).toBe("token1");
254
- });
255
-
256
- it("getCurrentAccount returns null when no accounts", async () => {
257
- loadAccounts.mockResolvedValue(null);
258
- const empty = await AccountManager.load(DEFAULT_CONFIG, null);
259
- expect(empty.getCurrentAccount()).toBeNull();
260
- });
261
-
262
- it("getCurrentAccount updates lastUsed", () => {
263
- const before = manager.getAccountsSnapshot()[0].lastUsed;
264
- vi.advanceTimersByTime(1000);
265
- manager.getCurrentAccount();
266
- const after = manager.getAccountsSnapshot()[0].lastUsed;
267
- expect(after).toBeGreaterThan(before);
268
- });
269
- });
270
-
271
- // ---------------------------------------------------------------------------
272
- // Rate limiting and health
273
- // ---------------------------------------------------------------------------
274
-
275
- describe("AccountManager rate limiting", () => {
276
- /** @type {AccountManager} */
277
- let manager;
278
-
279
- beforeEach(async () => {
280
- vi.resetAllMocks();
281
- vi.useFakeTimers();
282
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
283
- loadAccounts.mockResolvedValue(null);
284
- manager = await AccountManager.load(DEFAULT_CONFIG, {
285
- refresh: "token1",
286
- access: "access1",
287
- expires: Date.now() + 3600_000,
288
- });
289
- manager.addAccount("token2", "access2", Date.now() + 3600_000);
290
- });
291
-
292
- it("markRateLimited sets backoff and returns duration", () => {
293
- const account = manager.getCurrentAccount();
294
- const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
295
- expect(backoffMs).toBeGreaterThan(0);
296
- expect(account.consecutiveFailures).toBe(1);
297
- });
298
-
299
- it("markRateLimited increments consecutive failures", () => {
300
- const account = manager.getCurrentAccount();
301
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
302
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
303
- expect(account.consecutiveFailures).toBe(2);
304
- });
305
-
306
- it("markSuccess resets consecutive failures", () => {
307
- const account = manager.getCurrentAccount();
308
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
309
- expect(account.consecutiveFailures).toBe(1);
310
- manager.markSuccess(account);
311
- expect(account.consecutiveFailures).toBe(0);
312
- expect(account.lastFailureTime).toBeNull();
313
- });
314
-
315
- it("markFailure reduces health score", () => {
316
- const account = manager.getCurrentAccount();
317
- manager.markFailure(account);
318
- // Can't directly check health score, but we can verify it doesn't crash
319
- expect(account).toBeDefined();
320
- });
321
-
322
- it("failure TTL resets consecutive failures after timeout", () => {
323
- const account = manager.getCurrentAccount();
324
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
325
- expect(account.consecutiveFailures).toBe(1);
326
-
327
- // Advance past failure TTL (3600 seconds)
328
- vi.advanceTimersByTime(3601_000);
329
-
330
- // Next rate limit should reset the counter first
331
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
332
- expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
333
- });
334
-
335
- // ── Fast-mode pool isolation (Anthropic: fast has a SEPARATE rate-limit pool) ──
336
- it("fast-bucket rate limit does NOT block standard requests on the same account", () => {
337
- const account = manager.getCurrentAccount();
338
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
339
- // Fast pool is limited...
340
- expect(manager.isFastRateLimited(account)).toBe(true);
341
- // ...but the account is still selectable for standard requests (standard
342
- // bucket untouched, so getCurrentAccount still returns it).
343
- expect(manager.getCurrentAccount()).not.toBeNull();
344
- });
345
-
346
- it("fast-bucket rate limit does NOT increment consecutiveFailures or set lastFailureTime", () => {
347
- const account = manager.getCurrentAccount();
348
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
349
- expect(account.consecutiveFailures).toBe(0);
350
- expect(account.lastFailureTime).toBeNull();
351
- expect(manager.isFastRateLimited(account)).toBe(true);
352
- });
353
-
354
- it("standard rate limit does NOT mark the fast bucket as limited", () => {
355
- const account = manager.getCurrentAccount();
356
- manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
357
- expect(manager.isFastRateLimited(account)).toBe(false);
358
- });
359
-
360
- it("fast-bucket backoff returns a positive duration and expires independently", () => {
361
- const account = manager.getCurrentAccount();
362
- const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null, RATE_LIMIT_KEY_FAST);
363
- expect(backoffMs).toBeGreaterThan(0);
364
- expect(manager.isFastRateLimited(account)).toBe(true);
365
- // After the backoff expires, the fast pool is available again.
366
- vi.advanceTimersByTime(backoffMs + 1000);
367
- expect(manager.isFastRateLimited(account)).toBe(false);
368
- });
369
- });
370
-
371
- // ---------------------------------------------------------------------------
372
- // Persistence
373
- // ---------------------------------------------------------------------------
374
-
375
- describe("AccountManager persistence", () => {
376
- /** @type {AccountManager} */
377
- let manager;
378
-
379
- beforeEach(async () => {
380
- vi.resetAllMocks();
381
- vi.useFakeTimers();
382
- vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
383
- loadAccounts.mockResolvedValue(null);
384
- saveAccounts.mockResolvedValue(undefined);
385
- manager = await AccountManager.load(DEFAULT_CONFIG, {
386
- refresh: "token1",
387
- access: "access1",
388
- expires: Date.now() + 3600_000,
389
- });
390
- });
391
-
392
- it("saveToDisk calls saveAccounts with correct format", async () => {
393
- await manager.saveToDisk();
394
- // M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
395
- const savedArg = saveAccounts.mock.calls[0][0];
396
- expect(savedArg.version).toBe(1);
397
- expect(savedArg.accounts).toEqual(
398
- expect.arrayContaining([
399
- expect.objectContaining({
400
- refreshToken: "token1",
401
- enabled: true,
402
- }),
403
- ]),
404
- );
405
- expect(typeof savedArg.activeIndex).toBe("number");
406
- });
407
-
408
- it("requestSaveToDisk debounces saves", async () => {
409
- manager.requestSaveToDisk();
410
- manager.requestSaveToDisk();
411
- manager.requestSaveToDisk();
412
-
413
- // Should not have saved yet
414
- expect(saveAccounts).not.toHaveBeenCalled();
415
-
416
- // Advance past debounce timeout
417
- vi.advanceTimersByTime(1100);
418
-
419
- // Should have saved once
420
- // Wait for the async save to complete
421
- await vi.runAllTimersAsync();
422
- expect(saveAccounts).toHaveBeenCalledTimes(1);
423
- });
424
-
425
- it("requestSaveToDisk resets timer on subsequent calls", async () => {
426
- manager.requestSaveToDisk();
427
- vi.advanceTimersByTime(500); // Half the debounce window
428
- manager.requestSaveToDisk(); // Should reset the timer
429
- vi.advanceTimersByTime(500); // 500ms after second call (total 1000ms)
430
- expect(saveAccounts).not.toHaveBeenCalled(); // Timer was reset
431
- vi.advanceTimersByTime(600); // Now past the debounce window
432
- await vi.runAllTimersAsync();
433
- expect(saveAccounts).toHaveBeenCalledTimes(1);
434
- });
435
-
436
- it("toAuthDetails converts to OpenCode format", () => {
437
- const account = manager.getCurrentAccount();
438
- const details = manager.toAuthDetails(account);
439
- expect(details).toEqual({
440
- type: "oauth",
441
- refresh: "token1",
442
- access: "access1",
443
- expires: expect.any(Number),
444
- });
445
- });
446
-
447
- it("syncActiveIndexFromDisk picks up CLI changes", async () => {
448
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
449
-
450
- // Currently on account 0
451
- expect(manager.getCurrentIndex()).toBe(0);
452
-
453
- // CLI changes activeIndex to 1 on disk
454
- loadAccounts.mockResolvedValue(
455
- makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }], { activeIndex: 1 }),
456
- );
457
-
458
- await manager.syncActiveIndexFromDisk();
459
- expect(manager.getCurrentIndex()).toBe(1);
460
- });
461
-
462
- it("syncActiveIndexFromDisk ignores disabled target account", async () => {
463
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
464
-
465
- expect(manager.getCurrentIndex()).toBe(0);
466
-
467
- loadAccounts.mockResolvedValue(
468
- makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com", enabled: false }], { activeIndex: 1 }),
469
- );
470
-
471
- await manager.syncActiveIndexFromDisk();
472
- // Should stay on 0 because account 1 is disabled
473
- expect(manager.getCurrentIndex()).toBe(0);
474
- });
475
-
476
- it("syncActiveIndexFromDisk no-ops when disk matches memory", async () => {
477
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
478
-
479
- expect(manager.getCurrentIndex()).toBe(0);
480
-
481
- loadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }]));
482
-
483
- await manager.syncActiveIndexFromDisk();
484
- expect(manager.getCurrentIndex()).toBe(0);
485
- });
486
-
487
- it("syncActiveIndexFromDisk reconciles removed accounts from disk", async () => {
488
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
489
-
490
- loadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }]));
491
-
492
- await manager.syncActiveIndexFromDisk();
493
- await manager.saveToDisk();
494
-
495
- const saved = saveAccounts.mock.calls.at(-1)?.[0];
496
- expect(saved.accounts).toHaveLength(1);
497
- expect(saved.accounts[0].refreshToken).toBe("token1");
498
- });
499
-
500
- it("syncActiveIndexFromDisk updates enabled state from disk", async () => {
501
- manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
502
-
503
- loadAccounts.mockResolvedValue(
504
- makeAccountsData([{ email: "a@test.com", enabled: false }, { email: "b@test.com" }], { activeIndex: 1 }),
505
- );
506
-
507
- await manager.syncActiveIndexFromDisk();
508
- expect(manager.getCurrentIndex()).toBe(1);
509
- });
510
-
511
- it("syncActiveIndexFromDisk reconciles by stable id when refresh token rotates", async () => {
512
- const current = manager.getAccountsSnapshot()[0];
513
-
514
- loadAccounts.mockResolvedValue(
515
- makeAccountsData([
516
- {
517
- id: current.id,
518
- refreshToken: "token1-rotated",
519
- access: "rotated-access",
520
- expires: Date.now() + 7200_000,
521
- token_updated_at: Date.now() + 5_000,
522
- },
523
- ]),
524
- );
525
-
526
- await manager.syncActiveIndexFromDisk();
527
- const snapshot = manager.getAccountsSnapshot();
528
-
529
- expect(snapshot[0].id).toBe(current.id);
530
- expect(snapshot[0].refreshToken).toBe("token1-rotated");
531
- expect(snapshot[0].access).toBe("rotated-access");
532
- });
533
- });
534
-
535
- // ---------------------------------------------------------------------------
536
- // Usage stats
537
- // ---------------------------------------------------------------------------
538
-
539
- describe("AccountManager usage stats", () => {
540
- beforeEach(() => {
541
- vi.clearAllMocks();
542
- });
543
-
544
- async function createManagerWithAccounts(n = 2) {
545
- loadAccounts.mockResolvedValue(null);
546
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
547
- refresh: "tok-1",
548
- access: "access-1",
549
- expires: Date.now() + 3600_000,
550
- });
551
- for (let i = 2; i <= n; i++) {
552
- manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
553
- }
554
- return manager;
555
- }
556
-
557
- it("recordUsage increments stats for the given account", async () => {
558
- const manager = await createManagerWithAccounts(2);
559
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 5 });
560
-
561
- const snap = manager.getAccountsSnapshot();
562
- expect(snap[0].stats.requests).toBe(1);
563
- expect(snap[0].stats.inputTokens).toBe(100);
564
- expect(snap[0].stats.outputTokens).toBe(50);
565
- expect(snap[0].stats.cacheReadTokens).toBe(10);
566
- expect(snap[0].stats.cacheWriteTokens).toBe(5);
567
- // Account 1 should be untouched
568
- expect(snap[1].stats.requests).toBe(0);
569
- });
570
-
571
- it("recordUsage accumulates over multiple calls", async () => {
572
- const manager = await createManagerWithAccounts(1);
573
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
574
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
575
-
576
- const snap = manager.getAccountsSnapshot();
577
- expect(snap[0].stats.requests).toBe(2);
578
- expect(snap[0].stats.inputTokens).toBe(300);
579
- expect(snap[0].stats.outputTokens).toBe(150);
580
- });
581
-
582
- it("recordUsage handles missing fields gracefully", async () => {
583
- const manager = await createManagerWithAccounts(1);
584
- manager.recordUsage(0, {});
585
-
586
- const snap = manager.getAccountsSnapshot();
587
- expect(snap[0].stats.requests).toBe(1);
588
- expect(snap[0].stats.inputTokens).toBe(0);
589
- });
590
-
591
- it("recordUsage ignores invalid index", async () => {
592
- const manager = await createManagerWithAccounts(1);
593
- manager.recordUsage(99, { inputTokens: 100 });
594
- // Should not throw
595
- const snap = manager.getAccountsSnapshot();
596
- expect(snap[0].stats.requests).toBe(0);
597
- });
598
-
599
- it("resetStats resets a single account", async () => {
600
- const manager = await createManagerWithAccounts(2);
601
- manager.recordUsage(0, { inputTokens: 500, outputTokens: 200 });
602
- manager.recordUsage(1, { inputTokens: 300, outputTokens: 100 });
603
-
604
- manager.resetStats(0);
605
-
606
- const snap = manager.getAccountsSnapshot();
607
- expect(snap[0].stats.requests).toBe(0);
608
- expect(snap[0].stats.inputTokens).toBe(0);
609
- // Account 1 should be untouched
610
- expect(snap[1].stats.requests).toBe(1);
611
- expect(snap[1].stats.inputTokens).toBe(300);
612
- });
613
-
614
- it("resetStats ignores invalid index", async () => {
615
- const manager = await createManagerWithAccounts(1);
616
- manager.recordUsage(0, { inputTokens: 500 });
617
-
618
- manager.resetStats(99);
619
-
620
- // Account 0 should be untouched
621
- const snap = manager.getAccountsSnapshot();
622
- expect(snap[0].stats.requests).toBe(1);
623
- expect(snap[0].stats.inputTokens).toBe(500);
624
- });
625
-
626
- it("resetStats resets all accounts", async () => {
627
- const manager = await createManagerWithAccounts(2);
628
- manager.recordUsage(0, { inputTokens: 500 });
629
- manager.recordUsage(1, { inputTokens: 300 });
630
-
631
- manager.resetStats("all");
632
-
633
- const snap = manager.getAccountsSnapshot();
634
- expect(snap[0].stats.requests).toBe(0);
635
- expect(snap[1].stats.requests).toBe(0);
636
- });
637
-
638
- it("stats are included in saveToDisk output", async () => {
639
- const manager = await createManagerWithAccounts(1);
640
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
641
-
642
- await manager.saveToDisk();
643
-
644
- // M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
645
- const savedArg = saveAccounts.mock.calls[0][0];
646
- expect(savedArg.accounts).toEqual(
647
- expect.arrayContaining([
648
- expect.objectContaining({
649
- stats: expect.objectContaining({
650
- requests: 1,
651
- inputTokens: 100,
652
- outputTokens: 50,
653
- }),
654
- }),
655
- ]),
656
- );
657
- });
658
- });
659
-
660
- // ---------------------------------------------------------------------------
661
- // Merge-on-save (concurrent instance support)
662
- // ---------------------------------------------------------------------------
663
-
664
- describe("AccountManager merge-on-save", () => {
665
- beforeEach(() => {
666
- vi.clearAllMocks();
667
- });
668
-
669
- async function createManagerWithAccounts(n = 1) {
670
- loadAccounts.mockResolvedValue(null);
671
- const manager = await AccountManager.load(DEFAULT_CONFIG, {
672
- refresh: "tok-1",
673
- access: "access-1",
674
- expires: Date.now() + 3600_000,
675
- });
676
- for (let i = 2; i <= n; i++) {
677
- manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
678
- }
679
- // Save once to establish baseline, then clear mocks
680
- await manager.saveToDisk();
681
- vi.clearAllMocks();
682
- return manager;
683
- }
684
-
685
- it("merges stats with disk values on save", async () => {
686
- const manager = await createManagerWithAccounts(1);
687
- const snap = manager.getAccountsSnapshot();
688
- const accountId = snap[0].id;
689
-
690
- // Simulate another instance having written stats to disk
691
- loadAccounts.mockResolvedValue(
692
- makeAccountsData([
693
- {
694
- id: accountId,
695
- refreshToken: "tok-1",
696
- stats: {
697
- requests: 50,
698
- inputTokens: 10000,
699
- outputTokens: 5000,
700
- cacheReadTokens: 1000,
701
- cacheWriteTokens: 500,
702
- lastReset: 1000,
703
- },
704
- },
705
- ]),
706
- );
707
- saveAccounts.mockResolvedValue(undefined);
708
-
709
- // This instance records 3 requests
710
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 5 });
711
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
712
- manager.recordUsage(0, { inputTokens: 300, outputTokens: 150 });
713
-
714
- await manager.saveToDisk();
715
-
716
- const saved = saveAccounts.mock.calls[0][0];
717
- const stats = saved.accounts[0].stats;
718
-
719
- // Should be disk values + our deltas
720
- expect(stats.requests).toBe(53); // 50 + 3
721
- expect(stats.inputTokens).toBe(10600); // 10000 + 100 + 200 + 300
722
- expect(stats.outputTokens).toBe(5300); // 5000 + 50 + 100 + 150
723
- expect(stats.cacheReadTokens).toBe(1010); // 1000 + 10
724
- expect(stats.cacheWriteTokens).toBe(505); // 500 + 5
725
- expect(stats.lastReset).toBe(1000); // Preserved from disk
726
- });
727
-
728
- it("clears deltas after save", async () => {
729
- const manager = await createManagerWithAccounts(1);
730
- const snap = manager.getAccountsSnapshot();
731
- const accountId = snap[0].id;
732
-
733
- loadAccounts.mockResolvedValue(
734
- makeAccountsData([
735
- {
736
- id: accountId,
737
- refreshToken: "tok-1",
738
- stats: {
739
- requests: 10,
740
- inputTokens: 1000,
741
- outputTokens: 500,
742
- cacheReadTokens: 0,
743
- cacheWriteTokens: 0,
744
- lastReset: 1000,
745
- },
746
- },
747
- ]),
748
- );
749
- saveAccounts.mockResolvedValue(undefined);
750
-
751
- manager.recordUsage(0, { inputTokens: 100 });
752
- await manager.saveToDisk();
753
-
754
- // First save: 10 + 1 = 11 requests
755
- expect(saveAccounts.mock.calls[0][0].accounts[0].stats.requests).toBe(11);
756
-
757
- // Second save with no new usage should write disk values as-is (no delta)
758
- loadAccounts.mockResolvedValue(
759
- makeAccountsData([
760
- {
761
- id: accountId,
762
- refreshToken: "tok-1",
763
- stats: {
764
- requests: 11,
765
- inputTokens: 1100,
766
- outputTokens: 500,
767
- cacheReadTokens: 0,
768
- cacheWriteTokens: 0,
769
- lastReset: 1000,
770
- },
771
- },
772
- ]),
773
- );
774
-
775
- await manager.saveToDisk();
776
- // No delta, so stats should match what's on disk
777
- expect(saveAccounts.mock.calls[1][0].accounts[0].stats.requests).toBe(11);
778
- });
779
-
780
- it("resetStats writes absolute values ignoring disk", async () => {
781
- const manager = await createManagerWithAccounts(1);
782
- const snap = manager.getAccountsSnapshot();
783
- const accountId = snap[0].id;
784
-
785
- // Disk has 100 requests from other instances
786
- loadAccounts.mockResolvedValue(
787
- makeAccountsData([
788
- {
789
- id: accountId,
790
- refreshToken: "tok-1",
791
- stats: {
792
- requests: 100,
793
- inputTokens: 50000,
794
- outputTokens: 20000,
795
- cacheReadTokens: 0,
796
- cacheWriteTokens: 0,
797
- lastReset: 1000,
798
- },
799
- },
800
- ]),
801
- );
802
- saveAccounts.mockResolvedValue(undefined);
803
-
804
- manager.resetStats(0);
805
- await manager.saveToDisk();
806
-
807
- const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
808
- expect(stats.requests).toBe(0);
809
- expect(stats.inputTokens).toBe(0);
810
- expect(stats.outputTokens).toBe(0);
811
- });
812
-
813
- it("accumulates usage after resetStats correctly", async () => {
814
- const manager = await createManagerWithAccounts(1);
815
- const snap = manager.getAccountsSnapshot();
816
- const accountId = snap[0].id;
817
-
818
- loadAccounts.mockResolvedValue(
819
- makeAccountsData([
820
- {
821
- id: accountId,
822
- refreshToken: "tok-1",
823
- stats: {
824
- requests: 100,
825
- inputTokens: 50000,
826
- outputTokens: 20000,
827
- cacheReadTokens: 0,
828
- cacheWriteTokens: 0,
829
- lastReset: 1000,
830
- },
831
- },
832
- ]),
833
- );
834
- saveAccounts.mockResolvedValue(undefined);
835
-
836
- // Reset then record new usage before saving
837
- manager.resetStats(0);
838
- manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
839
-
840
- await manager.saveToDisk();
841
-
842
- const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
843
- expect(stats.requests).toBe(1); // 0 (reset) + 1
844
- expect(stats.inputTokens).toBe(200); // 0 (reset) + 200
845
- expect(stats.outputTokens).toBe(100); // 0 (reset) + 100
846
- });
847
-
848
- it("falls through to absolute values when disk read fails", async () => {
849
- const manager = await createManagerWithAccounts(1);
850
-
851
- // Disk read fails
852
- loadAccounts.mockRejectedValue(new Error("disk error"));
853
- saveAccounts.mockResolvedValue(undefined);
854
-
855
- manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
856
- await manager.saveToDisk();
857
-
858
- // Should write in-memory stats as-is
859
- const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
860
- expect(stats.requests).toBe(1);
861
- expect(stats.inputTokens).toBe(100);
862
- });
863
-
864
- it("does not let stale in-memory auth overwrite fresher disk auth", async () => {
865
- const manager = await createManagerWithAccounts(1);
866
- const account = manager.getAccountsSnapshot()[0];
867
-
868
- loadAccounts.mockResolvedValue(
869
- makeAccountsData([
870
- {
871
- id: account.id,
872
- refreshToken: "disk-rotated-refresh",
873
- access: "disk-fresh-access",
874
- expires: Date.now() + 9_000_000,
875
- token_updated_at: Date.now() + 10_000,
876
- },
877
- ]),
878
- );
879
-
880
- await manager.saveToDisk();
881
-
882
- const saved = saveAccounts.mock.calls[0][0];
883
- expect(saved.accounts[0].refreshToken).toBe("disk-rotated-refresh");
884
- expect(saved.accounts[0].access).toBe("disk-fresh-access");
885
- expect(saved.accounts[0].token_updated_at).toBeGreaterThan(account.tokenUpdatedAt);
886
- });
887
-
888
- it("matches id-less disk records by addedAt when merging auth freshness", async () => {
889
- const manager = await createManagerWithAccounts(1);
890
- const account = manager.getAccountsSnapshot()[0];
891
-
892
- // Simulate legacy/id-less disk record after token rotation.
893
- loadAccounts.mockResolvedValue(
894
- makeAccountsData([
895
- {
896
- id: undefined,
897
- addedAt: account.addedAt,
898
- refreshToken: "disk-rotated-no-id",
899
- access: "disk-fresh-no-id",
900
- expires: Date.now() + 9_000_000,
901
- token_updated_at: Date.now() + 10_000,
902
- },
903
- ]),
904
- );
905
-
906
- await manager.saveToDisk();
907
-
908
- const saved = saveAccounts.mock.calls[0][0];
909
- expect(saved.accounts[0].refreshToken).toBe("disk-rotated-no-id");
910
- expect(saved.accounts[0].access).toBe("disk-fresh-no-id");
911
- });
912
- });