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
package/lib/accounts.mjs
ADDED
|
@@ -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
|
+
}
|