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.
@@ -0,0 +1,670 @@
1
+ import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
2
+ import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
3
+ import { calculateBackoffMs } from "./backoff.mjs";
4
+
5
+ /**
6
+ * @typedef {import('./config.mjs').AnthropicAuthConfig} AnthropicAuthConfig
7
+ * @typedef {import('./config.mjs').AccountSelectionStrategy} AccountSelectionStrategy
8
+ * @typedef {import('./storage.mjs').AccountMetadata} AccountMetadata
9
+ * @typedef {import('./storage.mjs').AccountStorage} AccountStorage
10
+ * @typedef {import('./backoff.mjs').RateLimitReason} RateLimitReason
11
+ */
12
+
13
+ /**
14
+ * @typedef {import('./storage.mjs').AccountStats} AccountStats
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} ManagedAccount
19
+ * @property {string} id
20
+ * @property {number} index
21
+ * @property {string} [email]
22
+ * @property {string} refreshToken
23
+ * @property {string} [access]
24
+ * @property {number} [expires]
25
+ * @property {number} addedAt
26
+ * @property {number} lastUsed
27
+ * @property {boolean} enabled
28
+ * @property {Record<string, number>} rateLimitResetTimes
29
+ * @property {number} consecutiveFailures
30
+ * @property {number | null} lastFailureTime
31
+ * @property {string} [lastSwitchReason]
32
+ * @property {AccountStats} stats
33
+ */
34
+
35
+ const MAX_ACCOUNTS = 10;
36
+ const RATE_LIMIT_KEY = "anthropic";
37
+
38
+ /**
39
+ * @typedef {object} StatsDelta
40
+ * @property {number} requests
41
+ * @property {number} inputTokens
42
+ * @property {number} outputTokens
43
+ * @property {number} cacheReadTokens
44
+ * @property {number} cacheWriteTokens
45
+ * @property {boolean} isReset - If true, this delta represents an absolute reset, not an increment
46
+ * @property {number} [resetTimestamp] - The lastReset value when isReset is true
47
+ */
48
+
49
+ export class AccountManager {
50
+ /** @type {ManagedAccount[]} */
51
+ #accounts = [];
52
+ /** @type {number} */
53
+ #cursor = 0;
54
+ /** @type {number} */
55
+ #currentIndex = -1;
56
+ /** @type {HealthScoreTracker} */
57
+ #healthTracker;
58
+ /** @type {TokenBucketTracker} */
59
+ #tokenTracker;
60
+ /** @type {AnthropicAuthConfig} */
61
+ #config;
62
+ /** @type {ReturnType<typeof setTimeout> | null} */
63
+ #saveTimeout = null;
64
+ /**
65
+ * Pending stats deltas per account id, merged into disk values on save.
66
+ * @type {Map<string, StatsDelta>}
67
+ */
68
+ #statsDeltas = new Map();
69
+
70
+ /**
71
+ * @param {AnthropicAuthConfig} config
72
+ */
73
+ constructor(config) {
74
+ this.#config = config;
75
+ this.#healthTracker = new HealthScoreTracker(config.health_score);
76
+ this.#tokenTracker = new TokenBucketTracker(config.token_bucket);
77
+ }
78
+
79
+ /**
80
+ * Load accounts from disk, optionally merging with an OpenCode auth fallback.
81
+ * @param {AnthropicAuthConfig} config
82
+ * @param {{refresh: string, access?: string, expires?: number} | null} [authFallback]
83
+ * @returns {Promise<AccountManager>}
84
+ */
85
+ static async load(config, authFallback) {
86
+ const manager = new AccountManager(config);
87
+ const stored = await loadAccounts();
88
+
89
+ // If storage exists (even with zero accounts), treat disk as authoritative.
90
+ // This allows explicit CLI logout-all to remain logged out instead of
91
+ // re-bootstrapping from OpenCode auth fallback credentials.
92
+ if (stored) {
93
+ manager.#accounts = stored.accounts.map((acc, index) => ({
94
+ id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
95
+ index,
96
+ email: acc.email,
97
+ refreshToken: acc.refreshToken,
98
+ access: acc.access,
99
+ expires: acc.expires,
100
+ addedAt: acc.addedAt,
101
+ lastUsed: acc.lastUsed,
102
+ enabled: acc.enabled,
103
+ rateLimitResetTimes: acc.rateLimitResetTimes,
104
+ consecutiveFailures: acc.consecutiveFailures,
105
+ lastFailureTime: acc.lastFailureTime,
106
+ lastSwitchReason: acc.lastSwitchReason,
107
+ stats: acc.stats ?? createDefaultStats(acc.addedAt),
108
+ }));
109
+
110
+ manager.#currentIndex =
111
+ manager.#accounts.length > 0 ? Math.min(stored.activeIndex, manager.#accounts.length - 1) : -1;
112
+
113
+ // If we have a fallback auth, match it to an existing account and set tokens
114
+ if (authFallback && manager.#accounts.length > 0) {
115
+ const match = manager.#accounts.find((acc) => acc.refreshToken === authFallback.refresh);
116
+ if (match) {
117
+ match.access = authFallback.access;
118
+ match.expires = authFallback.expires;
119
+ }
120
+ }
121
+
122
+ return manager;
123
+ }
124
+
125
+ // No stored accounts — bootstrap from fallback if available
126
+ if (authFallback && authFallback.refresh) {
127
+ const now = Date.now();
128
+ manager.#accounts = [
129
+ {
130
+ id: `${now}:${authFallback.refresh.slice(0, 12)}`,
131
+ index: 0,
132
+ email: undefined,
133
+ refreshToken: authFallback.refresh,
134
+ access: authFallback.access,
135
+ expires: authFallback.expires,
136
+ addedAt: now,
137
+ lastUsed: 0,
138
+ enabled: true,
139
+ rateLimitResetTimes: {},
140
+ consecutiveFailures: 0,
141
+ lastFailureTime: null,
142
+ lastSwitchReason: "initial",
143
+ stats: createDefaultStats(now),
144
+ },
145
+ ];
146
+ manager.#currentIndex = 0;
147
+ }
148
+
149
+ return manager;
150
+ }
151
+
152
+ /**
153
+ * Get the number of enabled accounts.
154
+ * @returns {number}
155
+ */
156
+ getAccountCount() {
157
+ return this.#accounts.filter((acc) => acc.enabled).length;
158
+ }
159
+
160
+ /**
161
+ * Get the total number of accounts (including disabled).
162
+ * @returns {number}
163
+ */
164
+ getTotalAccountCount() {
165
+ return this.#accounts.length;
166
+ }
167
+
168
+ /**
169
+ * Get a snapshot of all accounts (for display/management).
170
+ * @returns {ManagedAccount[]}
171
+ */
172
+ getAccountsSnapshot() {
173
+ return this.#accounts.map((acc) => ({ ...acc }));
174
+ }
175
+
176
+ /**
177
+ * Get the current active account index.
178
+ * @returns {number}
179
+ */
180
+ getCurrentIndex() {
181
+ return this.#currentIndex;
182
+ }
183
+
184
+ /**
185
+ * Clear expired rate limits for an account.
186
+ * @param {ManagedAccount} account
187
+ */
188
+ #clearExpiredRateLimits(account) {
189
+ const now = Date.now();
190
+ for (const key of Object.keys(account.rateLimitResetTimes)) {
191
+ if (account.rateLimitResetTimes[key] <= now) {
192
+ delete account.rateLimitResetTimes[key];
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Check if an account is currently rate-limited.
199
+ * @param {ManagedAccount} account
200
+ * @returns {boolean}
201
+ */
202
+ #isRateLimited(account) {
203
+ this.#clearExpiredRateLimits(account);
204
+ const resetTime = account.rateLimitResetTimes[RATE_LIMIT_KEY];
205
+ return resetTime !== undefined && Date.now() < resetTime;
206
+ }
207
+
208
+ /**
209
+ * Select the best account for the current request.
210
+ * @param {Set<number>} [excludedIndices] - Temporary per-request exclusions
211
+ * @returns {ManagedAccount | null}
212
+ */
213
+ getCurrentAccount(excludedIndices) {
214
+ if (this.#accounts.length === 0) return null;
215
+
216
+ // Build candidates list
217
+ const candidates = this.#accounts
218
+ .filter((acc) => acc.enabled && !excludedIndices?.has(acc.index))
219
+ .map((acc) => {
220
+ this.#clearExpiredRateLimits(acc);
221
+ return {
222
+ index: acc.index,
223
+ lastUsed: acc.lastUsed,
224
+ healthScore: this.#healthTracker.getScore(acc.index),
225
+ isRateLimited: this.#isRateLimited(acc),
226
+ enabled: acc.enabled,
227
+ };
228
+ });
229
+
230
+ const result = selectAccount(
231
+ candidates,
232
+ this.#config.account_selection_strategy,
233
+ this.#currentIndex >= 0 ? this.#currentIndex : null,
234
+ this.#healthTracker,
235
+ this.#tokenTracker,
236
+ this.#cursor,
237
+ );
238
+
239
+ if (!result) return null;
240
+
241
+ this.#cursor = result.cursor;
242
+ this.#currentIndex = result.index;
243
+
244
+ const account = this.#accounts[result.index];
245
+ if (account) {
246
+ account.lastUsed = Date.now();
247
+ this.#tokenTracker.consume(account.index);
248
+ }
249
+
250
+ return account ?? null;
251
+ }
252
+
253
+ /**
254
+ * Mark an account as rate-limited.
255
+ * @param {ManagedAccount} account
256
+ * @param {RateLimitReason} reason
257
+ * @param {number | null} [retryAfterMs]
258
+ * @returns {number} The backoff duration in ms
259
+ */
260
+ markRateLimited(account, reason, retryAfterMs) {
261
+ const now = Date.now();
262
+
263
+ // Reset consecutive failures if TTL has expired
264
+ if (account.lastFailureTime !== null && now - account.lastFailureTime > this.#config.failure_ttl_seconds * 1000) {
265
+ account.consecutiveFailures = 0;
266
+ }
267
+
268
+ account.consecutiveFailures += 1;
269
+ account.lastFailureTime = now;
270
+
271
+ const backoffMs = calculateBackoffMs(reason, account.consecutiveFailures - 1, retryAfterMs);
272
+
273
+ account.rateLimitResetTimes[RATE_LIMIT_KEY] = now + backoffMs;
274
+
275
+ this.#healthTracker.recordRateLimit(account.index);
276
+
277
+ this.requestSaveToDisk();
278
+
279
+ return backoffMs;
280
+ }
281
+
282
+ /**
283
+ * Mark a successful request for an account.
284
+ * @param {ManagedAccount} account
285
+ */
286
+ markSuccess(account) {
287
+ account.consecutiveFailures = 0;
288
+ account.lastFailureTime = null;
289
+ this.#healthTracker.recordSuccess(account.index);
290
+ }
291
+
292
+ /**
293
+ * Mark a general failure (not rate limit) for an account.
294
+ * @param {ManagedAccount} account
295
+ */
296
+ markFailure(account) {
297
+ this.#healthTracker.recordFailure(account.index);
298
+ this.#tokenTracker.refund(account.index);
299
+ }
300
+
301
+ /**
302
+ * Add a new account to the pool.
303
+ * @param {string} refreshToken
304
+ * @param {string} accessToken
305
+ * @param {number} expires
306
+ * @param {string} [email]
307
+ * @returns {ManagedAccount | null} The new account, or null if at capacity
308
+ */
309
+ addAccount(refreshToken, accessToken, expires, email) {
310
+ if (this.#accounts.length >= MAX_ACCOUNTS) return null;
311
+
312
+ // Check for duplicate refresh token
313
+ const existing = this.#accounts.find((acc) => acc.refreshToken === refreshToken);
314
+ if (existing) {
315
+ existing.access = accessToken;
316
+ existing.expires = expires;
317
+ if (email) existing.email = email;
318
+ existing.enabled = true;
319
+ return existing;
320
+ }
321
+
322
+ const now = Date.now();
323
+ /** @type {ManagedAccount} */
324
+ const account = {
325
+ id: `${now}:${refreshToken.slice(0, 12)}`,
326
+ index: this.#accounts.length,
327
+ email,
328
+ refreshToken,
329
+ access: accessToken,
330
+ expires,
331
+ addedAt: now,
332
+ lastUsed: 0,
333
+ enabled: true,
334
+ rateLimitResetTimes: {},
335
+ consecutiveFailures: 0,
336
+ lastFailureTime: null,
337
+ lastSwitchReason: "initial",
338
+ stats: createDefaultStats(now),
339
+ };
340
+
341
+ this.#accounts.push(account);
342
+
343
+ // If this is the first account, make it active
344
+ if (this.#accounts.length === 1) {
345
+ this.#currentIndex = 0;
346
+ }
347
+
348
+ this.requestSaveToDisk();
349
+ return account;
350
+ }
351
+
352
+ /**
353
+ * Remove an account by index.
354
+ * @param {number} index
355
+ * @returns {boolean}
356
+ */
357
+ removeAccount(index) {
358
+ if (index < 0 || index >= this.#accounts.length) return false;
359
+
360
+ this.#accounts.splice(index, 1);
361
+
362
+ // Re-index remaining accounts
363
+ this.#accounts.forEach((acc, i) => {
364
+ acc.index = i;
365
+ });
366
+
367
+ // Adjust current index
368
+ if (this.#accounts.length === 0) {
369
+ this.#currentIndex = -1;
370
+ this.#cursor = 0;
371
+ } else {
372
+ if (this.#currentIndex >= this.#accounts.length) {
373
+ this.#currentIndex = this.#accounts.length - 1;
374
+ }
375
+ if (this.#cursor > 0) {
376
+ this.#cursor = Math.min(this.#cursor, this.#accounts.length);
377
+ }
378
+ }
379
+
380
+ // Reset health scores for all accounts since indices shifted after splice.
381
+ // This is simpler and safer than trying to remap individual scores.
382
+ for (let i = 0; i < this.#accounts.length; i++) {
383
+ this.#healthTracker.reset(i);
384
+ }
385
+ this.requestSaveToDisk();
386
+ return true;
387
+ }
388
+
389
+ /**
390
+ * Toggle an account's enabled state.
391
+ * @param {number} index
392
+ * @returns {boolean} New enabled state
393
+ */
394
+ toggleAccount(index) {
395
+ const account = this.#accounts[index];
396
+ if (!account) return false;
397
+
398
+ account.enabled = !account.enabled;
399
+ this.requestSaveToDisk();
400
+ return account.enabled;
401
+ }
402
+
403
+ /**
404
+ * Clear all accounts and reset state.
405
+ */
406
+ clearAll() {
407
+ this.#accounts = [];
408
+ this.#currentIndex = -1;
409
+ this.#cursor = 0;
410
+ }
411
+
412
+ /**
413
+ * Request a debounced save to disk.
414
+ * Each call resets the debounce timer so the latest state is always persisted.
415
+ */
416
+ requestSaveToDisk() {
417
+ if (this.#saveTimeout) clearTimeout(this.#saveTimeout);
418
+ this.#saveTimeout = setTimeout(() => {
419
+ this.#saveTimeout = null;
420
+ this.saveToDisk().catch(() => {});
421
+ }, 1000);
422
+ }
423
+
424
+ /**
425
+ * Persist current state to disk immediately.
426
+ * Stats use merge-on-save: read disk values, add this instance's deltas,
427
+ * write merged result. This prevents concurrent instances from clobbering
428
+ * each other's stats.
429
+ * @returns {Promise<void>}
430
+ */
431
+ async saveToDisk() {
432
+ // Read current disk state to merge stats
433
+ let diskAccounts = null;
434
+ try {
435
+ const diskData = await loadAccounts();
436
+ if (diskData) {
437
+ diskAccounts = new Map(diskData.accounts.map((a) => [a.id, a]));
438
+ }
439
+ } catch {
440
+ // If we can't read, fall through to writing absolute values
441
+ }
442
+
443
+ /** @type {AccountStorage} */
444
+ const storage = {
445
+ version: 1,
446
+ accounts: this.#accounts.map((acc) => {
447
+ const delta = this.#statsDeltas.get(acc.id);
448
+ let mergedStats = acc.stats;
449
+
450
+ if (delta) {
451
+ const diskAcc = diskAccounts?.get(acc.id);
452
+ const diskStats = diskAcc?.stats;
453
+
454
+ if (delta.isReset) {
455
+ // Absolute reset: start from zero + any post-reset usage
456
+ mergedStats = {
457
+ requests: delta.requests,
458
+ inputTokens: delta.inputTokens,
459
+ outputTokens: delta.outputTokens,
460
+ cacheReadTokens: delta.cacheReadTokens,
461
+ cacheWriteTokens: delta.cacheWriteTokens,
462
+ lastReset: delta.resetTimestamp ?? acc.stats.lastReset,
463
+ };
464
+ } else if (diskStats) {
465
+ // Incremental: add our deltas to whatever's on disk
466
+ mergedStats = {
467
+ requests: diskStats.requests + delta.requests,
468
+ inputTokens: diskStats.inputTokens + delta.inputTokens,
469
+ outputTokens: diskStats.outputTokens + delta.outputTokens,
470
+ cacheReadTokens: diskStats.cacheReadTokens + delta.cacheReadTokens,
471
+ cacheWriteTokens: diskStats.cacheWriteTokens + delta.cacheWriteTokens,
472
+ lastReset: diskStats.lastReset,
473
+ };
474
+ }
475
+ // If no diskStats and no reset, we write acc.stats as-is (first save)
476
+ }
477
+
478
+ return {
479
+ id: acc.id,
480
+ email: acc.email,
481
+ refreshToken: acc.refreshToken,
482
+ access: acc.access,
483
+ expires: acc.expires,
484
+ addedAt: acc.addedAt,
485
+ lastUsed: acc.lastUsed,
486
+ enabled: acc.enabled,
487
+ rateLimitResetTimes: Object.keys(acc.rateLimitResetTimes).length > 0 ? acc.rateLimitResetTimes : {},
488
+ consecutiveFailures: acc.consecutiveFailures,
489
+ lastFailureTime: acc.lastFailureTime,
490
+ lastSwitchReason: acc.lastSwitchReason,
491
+ stats: mergedStats,
492
+ };
493
+ }),
494
+ activeIndex: Math.max(0, this.#currentIndex),
495
+ };
496
+
497
+ await saveAccounts(storage);
498
+
499
+ // Clear deltas after successful write
500
+ this.#statsDeltas.clear();
501
+
502
+ // Update in-memory stats to match what we wrote (so subsequent reads
503
+ // reflect the merged values, not stale pre-merge values)
504
+ for (const saved of storage.accounts) {
505
+ const acc = this.#accounts.find((a) => a.id === saved.id);
506
+ if (acc) {
507
+ acc.stats = saved.stats;
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Sync activeIndex from disk (picks up CLI changes while OpenCode is running).
514
+ * Only switches if the disk value differs and the target account is enabled.
515
+ * @returns {Promise<void>}
516
+ */
517
+ async syncActiveIndexFromDisk() {
518
+ const stored = await loadAccounts();
519
+ if (!stored) return;
520
+
521
+ // Reconcile account list/enabled states with disk (CLI may add/remove/enable/disable)
522
+ const diskSnapshot = stored.accounts.map((acc) => `${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
523
+ const memSnapshot = this.#accounts.map((acc) => `${acc.refreshToken}:${acc.enabled ? 1 : 0}`).join("|");
524
+
525
+ if (diskSnapshot !== memSnapshot) {
526
+ const existingByToken = new Map(this.#accounts.map((acc) => [acc.refreshToken, acc]));
527
+
528
+ this.#accounts = stored.accounts.map((acc, index) => {
529
+ const existing = existingByToken.get(acc.refreshToken);
530
+ return {
531
+ id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
532
+ index,
533
+ email: acc.email ?? existing?.email,
534
+ refreshToken: acc.refreshToken,
535
+ access: acc.access ?? existing?.access,
536
+ expires: acc.expires ?? existing?.expires,
537
+ addedAt: acc.addedAt,
538
+ lastUsed: acc.lastUsed,
539
+ enabled: acc.enabled,
540
+ rateLimitResetTimes: acc.rateLimitResetTimes,
541
+ consecutiveFailures: acc.consecutiveFailures,
542
+ lastFailureTime: acc.lastFailureTime,
543
+ lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
544
+ stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
545
+ };
546
+ });
547
+
548
+ // Trackers are index-based; reset when account set/order changes.
549
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
550
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
551
+
552
+ // Prune orphaned stats deltas for accounts no longer in the set.
553
+ const currentIds = new Set(this.#accounts.map((a) => a.id));
554
+ for (const id of this.#statsDeltas.keys()) {
555
+ if (!currentIds.has(id)) this.#statsDeltas.delete(id);
556
+ }
557
+
558
+ if (this.#accounts.length === 0) {
559
+ this.#currentIndex = -1;
560
+ this.#cursor = 0;
561
+ return;
562
+ }
563
+ }
564
+
565
+ const diskIndex = Math.min(stored.activeIndex, this.#accounts.length - 1);
566
+ if (diskIndex >= 0 && diskIndex !== this.#currentIndex) {
567
+ const diskAccount = stored.accounts[diskIndex];
568
+ if (!diskAccount || !diskAccount.enabled) return;
569
+
570
+ const account = this.#accounts[diskIndex];
571
+ if (account && account.enabled) {
572
+ this.#currentIndex = diskIndex;
573
+ this.#cursor = diskIndex;
574
+ // Reset health so sticky strategy honors the explicit switch
575
+ this.#healthTracker.reset(diskIndex);
576
+ }
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Record token usage for an account after a successful API response.
582
+ * @param {number} index
583
+ * @param {{inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number}} usage
584
+ */
585
+ recordUsage(index, usage) {
586
+ const account = this.#accounts[index];
587
+ if (!account) return;
588
+
589
+ const inTok = usage.inputTokens || 0;
590
+ const outTok = usage.outputTokens || 0;
591
+ const crTok = usage.cacheReadTokens || 0;
592
+ const cwTok = usage.cacheWriteTokens || 0;
593
+
594
+ // Update in-memory stats (for reads within this process)
595
+ account.stats.requests += 1;
596
+ account.stats.inputTokens += inTok;
597
+ account.stats.outputTokens += outTok;
598
+ account.stats.cacheReadTokens += crTok;
599
+ account.stats.cacheWriteTokens += cwTok;
600
+
601
+ // Accumulate delta for merge-on-save
602
+ const delta = this.#statsDeltas.get(account.id);
603
+ if (delta) {
604
+ // For both incremental and reset deltas, accumulate usage on top.
605
+ delta.requests += 1;
606
+ delta.inputTokens += inTok;
607
+ delta.outputTokens += outTok;
608
+ delta.cacheReadTokens += crTok;
609
+ delta.cacheWriteTokens += cwTok;
610
+ } else {
611
+ this.#statsDeltas.set(account.id, {
612
+ requests: 1,
613
+ inputTokens: inTok,
614
+ outputTokens: outTok,
615
+ cacheReadTokens: crTok,
616
+ cacheWriteTokens: cwTok,
617
+ isReset: false,
618
+ });
619
+ }
620
+
621
+ this.requestSaveToDisk();
622
+ }
623
+
624
+ /**
625
+ * Reset stats for a specific account or all accounts.
626
+ * @param {number | "all"} target - Account index or "all"
627
+ */
628
+ resetStats(target) {
629
+ const now = Date.now();
630
+ const resetAccount = (/** @type {ManagedAccount} */ acc) => {
631
+ acc.stats = createDefaultStats(now);
632
+ // Mark as absolute reset — saveToDisk will write these values directly
633
+ this.#statsDeltas.set(acc.id, {
634
+ requests: 0,
635
+ inputTokens: 0,
636
+ outputTokens: 0,
637
+ cacheReadTokens: 0,
638
+ cacheWriteTokens: 0,
639
+ isReset: true,
640
+ resetTimestamp: now,
641
+ });
642
+ };
643
+
644
+ if (target === "all") {
645
+ for (const acc of this.#accounts) {
646
+ resetAccount(acc);
647
+ }
648
+ } else {
649
+ const account = this.#accounts[target];
650
+ if (account) {
651
+ resetAccount(account);
652
+ }
653
+ }
654
+ this.requestSaveToDisk();
655
+ }
656
+
657
+ /**
658
+ * Convert a managed account to the format expected by OpenCode's auth.json.
659
+ * @param {ManagedAccount} account
660
+ * @returns {{type: 'oauth', refresh: string, access: string | undefined, expires: number | undefined}}
661
+ */
662
+ toAuthDetails(account) {
663
+ return {
664
+ type: "oauth",
665
+ refresh: account.refreshToken,
666
+ access: account.access,
667
+ expires: account.expires,
668
+ };
669
+ }
670
+ }