opencode-anthropic-fix 0.0.14
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.
- package/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { AccountManager } 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("clamps activeIndex to valid range", async () => {
|
|
104
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ lastUsed: 2000 }], { activeIndex: 99 }));
|
|
105
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
106
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Account management
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe("AccountManager account management", () => {
|
|
115
|
+
/** @type {AccountManager} */
|
|
116
|
+
let manager;
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
vi.resetAllMocks();
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
122
|
+
loadAccounts.mockResolvedValue(null);
|
|
123
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
124
|
+
refresh: "token1",
|
|
125
|
+
access: "access1",
|
|
126
|
+
expires: Date.now() + 3600_000,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("addAccount adds a new account", () => {
|
|
131
|
+
const account = manager.addAccount("token2", "access2", Date.now() + 3600_000, "user@example.com");
|
|
132
|
+
expect(account).not.toBeNull();
|
|
133
|
+
expect(manager.getTotalAccountCount()).toBe(2);
|
|
134
|
+
expect(account.email).toBe("user@example.com");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("addAccount deduplicates by refresh token", () => {
|
|
138
|
+
manager.addAccount("token1", "new-access", Date.now() + 7200_000);
|
|
139
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
140
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
141
|
+
expect(snapshot[0].access).toBe("new-access");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("addAccount respects MAX_ACCOUNTS limit", () => {
|
|
145
|
+
for (let i = 2; i <= 10; i++) {
|
|
146
|
+
manager.addAccount(`token${i}`, `access${i}`, Date.now() + 3600_000);
|
|
147
|
+
}
|
|
148
|
+
expect(manager.getTotalAccountCount()).toBe(10);
|
|
149
|
+
const result = manager.addAccount("token11", "access11", Date.now() + 3600_000);
|
|
150
|
+
expect(result).toBeNull();
|
|
151
|
+
expect(manager.getTotalAccountCount()).toBe(10);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("removeAccount removes by index", () => {
|
|
155
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
156
|
+
expect(manager.getTotalAccountCount()).toBe(2);
|
|
157
|
+
const removed = manager.removeAccount(0);
|
|
158
|
+
expect(removed).toBe(true);
|
|
159
|
+
expect(manager.getTotalAccountCount()).toBe(1);
|
|
160
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
161
|
+
expect(snapshot[0].refreshToken).toBe("token2");
|
|
162
|
+
expect(snapshot[0].index).toBe(0); // Re-indexed
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("removeAccount returns false for invalid index", () => {
|
|
166
|
+
expect(manager.removeAccount(-1)).toBe(false);
|
|
167
|
+
expect(manager.removeAccount(99)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("removeAccount adjusts currentIndex", () => {
|
|
171
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
172
|
+
// Select account 1
|
|
173
|
+
manager.getCurrentAccount();
|
|
174
|
+
manager.removeAccount(0);
|
|
175
|
+
// currentIndex should be adjusted
|
|
176
|
+
expect(manager.getCurrentIndex()).toBeLessThanOrEqual(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("toggleAccount toggles enabled state", () => {
|
|
180
|
+
const newState = manager.toggleAccount(0);
|
|
181
|
+
expect(newState).toBe(false);
|
|
182
|
+
expect(manager.getAccountCount()).toBe(0); // Disabled
|
|
183
|
+
const restored = manager.toggleAccount(0);
|
|
184
|
+
expect(restored).toBe(true);
|
|
185
|
+
expect(manager.getAccountCount()).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("toggleAccount returns false for invalid index", () => {
|
|
189
|
+
expect(manager.toggleAccount(99)).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("clearAll removes all accounts", () => {
|
|
193
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
194
|
+
manager.clearAll();
|
|
195
|
+
expect(manager.getTotalAccountCount()).toBe(0);
|
|
196
|
+
expect(manager.getCurrentIndex()).toBe(-1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("getAccountsSnapshot returns copies", () => {
|
|
200
|
+
const snapshot = manager.getAccountsSnapshot();
|
|
201
|
+
snapshot[0].email = "modified";
|
|
202
|
+
const snapshot2 = manager.getAccountsSnapshot();
|
|
203
|
+
expect(snapshot2[0].email).not.toBe("modified");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Account selection
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
describe("AccountManager account selection", () => {
|
|
212
|
+
/** @type {AccountManager} */
|
|
213
|
+
let manager;
|
|
214
|
+
|
|
215
|
+
beforeEach(async () => {
|
|
216
|
+
vi.resetAllMocks();
|
|
217
|
+
vi.useFakeTimers();
|
|
218
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
219
|
+
loadAccounts.mockResolvedValue(null);
|
|
220
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
221
|
+
refresh: "token1",
|
|
222
|
+
access: "access1",
|
|
223
|
+
expires: Date.now() + 3600_000,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("getCurrentAccount returns an account", () => {
|
|
228
|
+
const account = manager.getCurrentAccount();
|
|
229
|
+
expect(account).not.toBeNull();
|
|
230
|
+
expect(account.refreshToken).toBe("token1");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("getCurrentAccount returns null when no accounts", async () => {
|
|
234
|
+
loadAccounts.mockResolvedValue(null);
|
|
235
|
+
const empty = await AccountManager.load(DEFAULT_CONFIG, null);
|
|
236
|
+
expect(empty.getCurrentAccount()).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("getCurrentAccount updates lastUsed", () => {
|
|
240
|
+
const before = manager.getAccountsSnapshot()[0].lastUsed;
|
|
241
|
+
vi.advanceTimersByTime(1000);
|
|
242
|
+
manager.getCurrentAccount();
|
|
243
|
+
const after = manager.getAccountsSnapshot()[0].lastUsed;
|
|
244
|
+
expect(after).toBeGreaterThan(before);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Rate limiting and health
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
describe("AccountManager rate limiting", () => {
|
|
253
|
+
/** @type {AccountManager} */
|
|
254
|
+
let manager;
|
|
255
|
+
|
|
256
|
+
beforeEach(async () => {
|
|
257
|
+
vi.resetAllMocks();
|
|
258
|
+
vi.useFakeTimers();
|
|
259
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
260
|
+
loadAccounts.mockResolvedValue(null);
|
|
261
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
262
|
+
refresh: "token1",
|
|
263
|
+
access: "access1",
|
|
264
|
+
expires: Date.now() + 3600_000,
|
|
265
|
+
});
|
|
266
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("markRateLimited sets backoff and returns duration", () => {
|
|
270
|
+
const account = manager.getCurrentAccount();
|
|
271
|
+
const backoffMs = manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
272
|
+
expect(backoffMs).toBeGreaterThan(0);
|
|
273
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("markRateLimited increments consecutive failures", () => {
|
|
277
|
+
const account = manager.getCurrentAccount();
|
|
278
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
279
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
280
|
+
expect(account.consecutiveFailures).toBe(2);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("markSuccess resets consecutive failures", () => {
|
|
284
|
+
const account = manager.getCurrentAccount();
|
|
285
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
286
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
287
|
+
manager.markSuccess(account);
|
|
288
|
+
expect(account.consecutiveFailures).toBe(0);
|
|
289
|
+
expect(account.lastFailureTime).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("markFailure reduces health score", () => {
|
|
293
|
+
const account = manager.getCurrentAccount();
|
|
294
|
+
manager.markFailure(account);
|
|
295
|
+
// Can't directly check health score, but we can verify it doesn't crash
|
|
296
|
+
expect(account).toBeDefined();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("failure TTL resets consecutive failures after timeout", () => {
|
|
300
|
+
const account = manager.getCurrentAccount();
|
|
301
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
302
|
+
expect(account.consecutiveFailures).toBe(1);
|
|
303
|
+
|
|
304
|
+
// Advance past failure TTL (3600 seconds)
|
|
305
|
+
vi.advanceTimersByTime(3601_000);
|
|
306
|
+
|
|
307
|
+
// Next rate limit should reset the counter first
|
|
308
|
+
manager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
|
|
309
|
+
expect(account.consecutiveFailures).toBe(1); // Reset to 0, then +1
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Persistence
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
describe("AccountManager persistence", () => {
|
|
318
|
+
/** @type {AccountManager} */
|
|
319
|
+
let manager;
|
|
320
|
+
|
|
321
|
+
beforeEach(async () => {
|
|
322
|
+
vi.resetAllMocks();
|
|
323
|
+
vi.useFakeTimers();
|
|
324
|
+
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
|
|
325
|
+
loadAccounts.mockResolvedValue(null);
|
|
326
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
327
|
+
manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
328
|
+
refresh: "token1",
|
|
329
|
+
access: "access1",
|
|
330
|
+
expires: Date.now() + 3600_000,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("saveToDisk calls saveAccounts with correct format", async () => {
|
|
335
|
+
await manager.saveToDisk();
|
|
336
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
337
|
+
expect.objectContaining({
|
|
338
|
+
version: 1,
|
|
339
|
+
accounts: expect.arrayContaining([
|
|
340
|
+
expect.objectContaining({
|
|
341
|
+
refreshToken: "token1",
|
|
342
|
+
enabled: true,
|
|
343
|
+
}),
|
|
344
|
+
]),
|
|
345
|
+
activeIndex: expect.any(Number),
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("requestSaveToDisk debounces saves", async () => {
|
|
351
|
+
manager.requestSaveToDisk();
|
|
352
|
+
manager.requestSaveToDisk();
|
|
353
|
+
manager.requestSaveToDisk();
|
|
354
|
+
|
|
355
|
+
// Should not have saved yet
|
|
356
|
+
expect(saveAccounts).not.toHaveBeenCalled();
|
|
357
|
+
|
|
358
|
+
// Advance past debounce timeout
|
|
359
|
+
vi.advanceTimersByTime(1100);
|
|
360
|
+
|
|
361
|
+
// Should have saved once
|
|
362
|
+
// Wait for the async save to complete
|
|
363
|
+
await vi.runAllTimersAsync();
|
|
364
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("requestSaveToDisk resets timer on subsequent calls", async () => {
|
|
368
|
+
manager.requestSaveToDisk();
|
|
369
|
+
vi.advanceTimersByTime(500); // Half the debounce window
|
|
370
|
+
manager.requestSaveToDisk(); // Should reset the timer
|
|
371
|
+
vi.advanceTimersByTime(500); // 500ms after second call (total 1000ms)
|
|
372
|
+
expect(saveAccounts).not.toHaveBeenCalled(); // Timer was reset
|
|
373
|
+
vi.advanceTimersByTime(600); // Now past the debounce window
|
|
374
|
+
await vi.runAllTimersAsync();
|
|
375
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("toAuthDetails converts to OpenCode format", () => {
|
|
379
|
+
const account = manager.getCurrentAccount();
|
|
380
|
+
const details = manager.toAuthDetails(account);
|
|
381
|
+
expect(details).toEqual({
|
|
382
|
+
type: "oauth",
|
|
383
|
+
refresh: "token1",
|
|
384
|
+
access: "access1",
|
|
385
|
+
expires: expect.any(Number),
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("syncActiveIndexFromDisk picks up CLI changes", async () => {
|
|
390
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
391
|
+
|
|
392
|
+
// Currently on account 0
|
|
393
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
394
|
+
|
|
395
|
+
// CLI changes activeIndex to 1 on disk
|
|
396
|
+
loadAccounts.mockResolvedValue(
|
|
397
|
+
makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }], { activeIndex: 1 }),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await manager.syncActiveIndexFromDisk();
|
|
401
|
+
expect(manager.getCurrentIndex()).toBe(1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("syncActiveIndexFromDisk ignores disabled target account", async () => {
|
|
405
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
406
|
+
|
|
407
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
408
|
+
|
|
409
|
+
loadAccounts.mockResolvedValue(
|
|
410
|
+
makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com", enabled: false }], { activeIndex: 1 }),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
await manager.syncActiveIndexFromDisk();
|
|
414
|
+
// Should stay on 0 because account 1 is disabled
|
|
415
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("syncActiveIndexFromDisk no-ops when disk matches memory", async () => {
|
|
419
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
420
|
+
|
|
421
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
422
|
+
|
|
423
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }, { email: "b@test.com" }]));
|
|
424
|
+
|
|
425
|
+
await manager.syncActiveIndexFromDisk();
|
|
426
|
+
expect(manager.getCurrentIndex()).toBe(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("syncActiveIndexFromDisk reconciles removed accounts from disk", async () => {
|
|
430
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
431
|
+
|
|
432
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ email: "a@test.com" }]));
|
|
433
|
+
|
|
434
|
+
await manager.syncActiveIndexFromDisk();
|
|
435
|
+
await manager.saveToDisk();
|
|
436
|
+
|
|
437
|
+
const saved = saveAccounts.mock.calls.at(-1)?.[0];
|
|
438
|
+
expect(saved.accounts).toHaveLength(1);
|
|
439
|
+
expect(saved.accounts[0].refreshToken).toBe("token1");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("syncActiveIndexFromDisk updates enabled state from disk", async () => {
|
|
443
|
+
manager.addAccount("token2", "access2", Date.now() + 3600_000, "b@test.com");
|
|
444
|
+
|
|
445
|
+
loadAccounts.mockResolvedValue(
|
|
446
|
+
makeAccountsData([{ email: "a@test.com", enabled: false }, { email: "b@test.com" }], { activeIndex: 1 }),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
await manager.syncActiveIndexFromDisk();
|
|
450
|
+
expect(manager.getCurrentIndex()).toBe(1);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Usage stats
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
describe("AccountManager usage stats", () => {
|
|
459
|
+
beforeEach(() => {
|
|
460
|
+
vi.clearAllMocks();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
async function createManagerWithAccounts(n = 2) {
|
|
464
|
+
loadAccounts.mockResolvedValue(null);
|
|
465
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
466
|
+
refresh: "tok-1",
|
|
467
|
+
access: "access-1",
|
|
468
|
+
expires: Date.now() + 3600_000,
|
|
469
|
+
});
|
|
470
|
+
for (let i = 2; i <= n; i++) {
|
|
471
|
+
manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
|
|
472
|
+
}
|
|
473
|
+
return manager;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
it("recordUsage increments stats for the given account", async () => {
|
|
477
|
+
const manager = await createManagerWithAccounts(2);
|
|
478
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 5 });
|
|
479
|
+
|
|
480
|
+
const snap = manager.getAccountsSnapshot();
|
|
481
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
482
|
+
expect(snap[0].stats.inputTokens).toBe(100);
|
|
483
|
+
expect(snap[0].stats.outputTokens).toBe(50);
|
|
484
|
+
expect(snap[0].stats.cacheReadTokens).toBe(10);
|
|
485
|
+
expect(snap[0].stats.cacheWriteTokens).toBe(5);
|
|
486
|
+
// Account 1 should be untouched
|
|
487
|
+
expect(snap[1].stats.requests).toBe(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("recordUsage accumulates over multiple calls", async () => {
|
|
491
|
+
const manager = await createManagerWithAccounts(1);
|
|
492
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
493
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
494
|
+
|
|
495
|
+
const snap = manager.getAccountsSnapshot();
|
|
496
|
+
expect(snap[0].stats.requests).toBe(2);
|
|
497
|
+
expect(snap[0].stats.inputTokens).toBe(300);
|
|
498
|
+
expect(snap[0].stats.outputTokens).toBe(150);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("recordUsage handles missing fields gracefully", async () => {
|
|
502
|
+
const manager = await createManagerWithAccounts(1);
|
|
503
|
+
manager.recordUsage(0, {});
|
|
504
|
+
|
|
505
|
+
const snap = manager.getAccountsSnapshot();
|
|
506
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
507
|
+
expect(snap[0].stats.inputTokens).toBe(0);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("recordUsage ignores invalid index", async () => {
|
|
511
|
+
const manager = await createManagerWithAccounts(1);
|
|
512
|
+
manager.recordUsage(99, { inputTokens: 100 });
|
|
513
|
+
// Should not throw
|
|
514
|
+
const snap = manager.getAccountsSnapshot();
|
|
515
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("resetStats resets a single account", async () => {
|
|
519
|
+
const manager = await createManagerWithAccounts(2);
|
|
520
|
+
manager.recordUsage(0, { inputTokens: 500, outputTokens: 200 });
|
|
521
|
+
manager.recordUsage(1, { inputTokens: 300, outputTokens: 100 });
|
|
522
|
+
|
|
523
|
+
manager.resetStats(0);
|
|
524
|
+
|
|
525
|
+
const snap = manager.getAccountsSnapshot();
|
|
526
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
527
|
+
expect(snap[0].stats.inputTokens).toBe(0);
|
|
528
|
+
// Account 1 should be untouched
|
|
529
|
+
expect(snap[1].stats.requests).toBe(1);
|
|
530
|
+
expect(snap[1].stats.inputTokens).toBe(300);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("resetStats ignores invalid index", async () => {
|
|
534
|
+
const manager = await createManagerWithAccounts(1);
|
|
535
|
+
manager.recordUsage(0, { inputTokens: 500 });
|
|
536
|
+
|
|
537
|
+
manager.resetStats(99);
|
|
538
|
+
|
|
539
|
+
// Account 0 should be untouched
|
|
540
|
+
const snap = manager.getAccountsSnapshot();
|
|
541
|
+
expect(snap[0].stats.requests).toBe(1);
|
|
542
|
+
expect(snap[0].stats.inputTokens).toBe(500);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("resetStats resets all accounts", async () => {
|
|
546
|
+
const manager = await createManagerWithAccounts(2);
|
|
547
|
+
manager.recordUsage(0, { inputTokens: 500 });
|
|
548
|
+
manager.recordUsage(1, { inputTokens: 300 });
|
|
549
|
+
|
|
550
|
+
manager.resetStats("all");
|
|
551
|
+
|
|
552
|
+
const snap = manager.getAccountsSnapshot();
|
|
553
|
+
expect(snap[0].stats.requests).toBe(0);
|
|
554
|
+
expect(snap[1].stats.requests).toBe(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("stats are included in saveToDisk output", async () => {
|
|
558
|
+
const manager = await createManagerWithAccounts(1);
|
|
559
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
560
|
+
|
|
561
|
+
await manager.saveToDisk();
|
|
562
|
+
|
|
563
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
564
|
+
expect.objectContaining({
|
|
565
|
+
accounts: expect.arrayContaining([
|
|
566
|
+
expect.objectContaining({
|
|
567
|
+
stats: expect.objectContaining({
|
|
568
|
+
requests: 1,
|
|
569
|
+
inputTokens: 100,
|
|
570
|
+
outputTokens: 50,
|
|
571
|
+
}),
|
|
572
|
+
}),
|
|
573
|
+
]),
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// Merge-on-save (concurrent instance support)
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
describe("AccountManager merge-on-save", () => {
|
|
584
|
+
beforeEach(() => {
|
|
585
|
+
vi.clearAllMocks();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
async function createManagerWithAccounts(n = 1) {
|
|
589
|
+
loadAccounts.mockResolvedValue(null);
|
|
590
|
+
const manager = await AccountManager.load(DEFAULT_CONFIG, {
|
|
591
|
+
refresh: "tok-1",
|
|
592
|
+
access: "access-1",
|
|
593
|
+
expires: Date.now() + 3600_000,
|
|
594
|
+
});
|
|
595
|
+
for (let i = 2; i <= n; i++) {
|
|
596
|
+
manager.addAccount(`tok-${i}`, `access-${i}`, Date.now() + 3600_000, `user${i}@test.com`);
|
|
597
|
+
}
|
|
598
|
+
// Save once to establish baseline, then clear mocks
|
|
599
|
+
await manager.saveToDisk();
|
|
600
|
+
vi.clearAllMocks();
|
|
601
|
+
return manager;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
it("merges stats with disk values on save", async () => {
|
|
605
|
+
const manager = await createManagerWithAccounts(1);
|
|
606
|
+
const snap = manager.getAccountsSnapshot();
|
|
607
|
+
const accountId = snap[0].id;
|
|
608
|
+
|
|
609
|
+
// Simulate another instance having written stats to disk
|
|
610
|
+
loadAccounts.mockResolvedValue(
|
|
611
|
+
makeAccountsData([
|
|
612
|
+
{
|
|
613
|
+
id: accountId,
|
|
614
|
+
refreshToken: "tok-1",
|
|
615
|
+
stats: {
|
|
616
|
+
requests: 50,
|
|
617
|
+
inputTokens: 10000,
|
|
618
|
+
outputTokens: 5000,
|
|
619
|
+
cacheReadTokens: 1000,
|
|
620
|
+
cacheWriteTokens: 500,
|
|
621
|
+
lastReset: 1000,
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
]),
|
|
625
|
+
);
|
|
626
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
627
|
+
|
|
628
|
+
// This instance records 3 requests
|
|
629
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10, cacheWriteTokens: 5 });
|
|
630
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
631
|
+
manager.recordUsage(0, { inputTokens: 300, outputTokens: 150 });
|
|
632
|
+
|
|
633
|
+
await manager.saveToDisk();
|
|
634
|
+
|
|
635
|
+
const saved = saveAccounts.mock.calls[0][0];
|
|
636
|
+
const stats = saved.accounts[0].stats;
|
|
637
|
+
|
|
638
|
+
// Should be disk values + our deltas
|
|
639
|
+
expect(stats.requests).toBe(53); // 50 + 3
|
|
640
|
+
expect(stats.inputTokens).toBe(10600); // 10000 + 100 + 200 + 300
|
|
641
|
+
expect(stats.outputTokens).toBe(5300); // 5000 + 50 + 100 + 150
|
|
642
|
+
expect(stats.cacheReadTokens).toBe(1010); // 1000 + 10
|
|
643
|
+
expect(stats.cacheWriteTokens).toBe(505); // 500 + 5
|
|
644
|
+
expect(stats.lastReset).toBe(1000); // Preserved from disk
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("clears deltas after save", async () => {
|
|
648
|
+
const manager = await createManagerWithAccounts(1);
|
|
649
|
+
const snap = manager.getAccountsSnapshot();
|
|
650
|
+
const accountId = snap[0].id;
|
|
651
|
+
|
|
652
|
+
loadAccounts.mockResolvedValue(
|
|
653
|
+
makeAccountsData([
|
|
654
|
+
{
|
|
655
|
+
id: accountId,
|
|
656
|
+
refreshToken: "tok-1",
|
|
657
|
+
stats: {
|
|
658
|
+
requests: 10,
|
|
659
|
+
inputTokens: 1000,
|
|
660
|
+
outputTokens: 500,
|
|
661
|
+
cacheReadTokens: 0,
|
|
662
|
+
cacheWriteTokens: 0,
|
|
663
|
+
lastReset: 1000,
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
]),
|
|
667
|
+
);
|
|
668
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
669
|
+
|
|
670
|
+
manager.recordUsage(0, { inputTokens: 100 });
|
|
671
|
+
await manager.saveToDisk();
|
|
672
|
+
|
|
673
|
+
// First save: 10 + 1 = 11 requests
|
|
674
|
+
expect(saveAccounts.mock.calls[0][0].accounts[0].stats.requests).toBe(11);
|
|
675
|
+
|
|
676
|
+
// Second save with no new usage should write disk values as-is (no delta)
|
|
677
|
+
loadAccounts.mockResolvedValue(
|
|
678
|
+
makeAccountsData([
|
|
679
|
+
{
|
|
680
|
+
id: accountId,
|
|
681
|
+
refreshToken: "tok-1",
|
|
682
|
+
stats: {
|
|
683
|
+
requests: 11,
|
|
684
|
+
inputTokens: 1100,
|
|
685
|
+
outputTokens: 500,
|
|
686
|
+
cacheReadTokens: 0,
|
|
687
|
+
cacheWriteTokens: 0,
|
|
688
|
+
lastReset: 1000,
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
]),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
await manager.saveToDisk();
|
|
695
|
+
// No delta, so stats should match what's on disk
|
|
696
|
+
expect(saveAccounts.mock.calls[1][0].accounts[0].stats.requests).toBe(11);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("resetStats writes absolute values ignoring disk", async () => {
|
|
700
|
+
const manager = await createManagerWithAccounts(1);
|
|
701
|
+
const snap = manager.getAccountsSnapshot();
|
|
702
|
+
const accountId = snap[0].id;
|
|
703
|
+
|
|
704
|
+
// Disk has 100 requests from other instances
|
|
705
|
+
loadAccounts.mockResolvedValue(
|
|
706
|
+
makeAccountsData([
|
|
707
|
+
{
|
|
708
|
+
id: accountId,
|
|
709
|
+
refreshToken: "tok-1",
|
|
710
|
+
stats: {
|
|
711
|
+
requests: 100,
|
|
712
|
+
inputTokens: 50000,
|
|
713
|
+
outputTokens: 20000,
|
|
714
|
+
cacheReadTokens: 0,
|
|
715
|
+
cacheWriteTokens: 0,
|
|
716
|
+
lastReset: 1000,
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
]),
|
|
720
|
+
);
|
|
721
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
722
|
+
|
|
723
|
+
manager.resetStats(0);
|
|
724
|
+
await manager.saveToDisk();
|
|
725
|
+
|
|
726
|
+
const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
727
|
+
expect(stats.requests).toBe(0);
|
|
728
|
+
expect(stats.inputTokens).toBe(0);
|
|
729
|
+
expect(stats.outputTokens).toBe(0);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("accumulates usage after resetStats correctly", async () => {
|
|
733
|
+
const manager = await createManagerWithAccounts(1);
|
|
734
|
+
const snap = manager.getAccountsSnapshot();
|
|
735
|
+
const accountId = snap[0].id;
|
|
736
|
+
|
|
737
|
+
loadAccounts.mockResolvedValue(
|
|
738
|
+
makeAccountsData([
|
|
739
|
+
{
|
|
740
|
+
id: accountId,
|
|
741
|
+
refreshToken: "tok-1",
|
|
742
|
+
stats: {
|
|
743
|
+
requests: 100,
|
|
744
|
+
inputTokens: 50000,
|
|
745
|
+
outputTokens: 20000,
|
|
746
|
+
cacheReadTokens: 0,
|
|
747
|
+
cacheWriteTokens: 0,
|
|
748
|
+
lastReset: 1000,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
]),
|
|
752
|
+
);
|
|
753
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
754
|
+
|
|
755
|
+
// Reset then record new usage before saving
|
|
756
|
+
manager.resetStats(0);
|
|
757
|
+
manager.recordUsage(0, { inputTokens: 200, outputTokens: 100 });
|
|
758
|
+
|
|
759
|
+
await manager.saveToDisk();
|
|
760
|
+
|
|
761
|
+
const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
762
|
+
expect(stats.requests).toBe(1); // 0 (reset) + 1
|
|
763
|
+
expect(stats.inputTokens).toBe(200); // 0 (reset) + 200
|
|
764
|
+
expect(stats.outputTokens).toBe(100); // 0 (reset) + 100
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("falls through to absolute values when disk read fails", async () => {
|
|
768
|
+
const manager = await createManagerWithAccounts(1);
|
|
769
|
+
|
|
770
|
+
// Disk read fails
|
|
771
|
+
loadAccounts.mockRejectedValue(new Error("disk error"));
|
|
772
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
773
|
+
|
|
774
|
+
manager.recordUsage(0, { inputTokens: 100, outputTokens: 50 });
|
|
775
|
+
await manager.saveToDisk();
|
|
776
|
+
|
|
777
|
+
// Should write in-memory stats as-is
|
|
778
|
+
const stats = saveAccounts.mock.calls[0][0].accounts[0].stats;
|
|
779
|
+
expect(stats.requests).toBe(1);
|
|
780
|
+
expect(stats.inputTokens).toBe(100);
|
|
781
|
+
});
|
|
782
|
+
});
|