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,346 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// HealthScoreTracker
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("HealthScoreTracker", () => {
|
|
9
|
+
/** @type {HealthScoreTracker} */
|
|
10
|
+
let tracker;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tracker = new HealthScoreTracker();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns initial score for unknown account", () => {
|
|
17
|
+
expect(tracker.getScore(0)).toBe(70);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("increases score on success", () => {
|
|
21
|
+
tracker.recordSuccess(0);
|
|
22
|
+
expect(tracker.getScore(0)).toBe(71);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("decreases score on rate limit", () => {
|
|
26
|
+
tracker.recordRateLimit(0);
|
|
27
|
+
expect(tracker.getScore(0)).toBe(60);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("decreases score on failure", () => {
|
|
31
|
+
tracker.recordFailure(0);
|
|
32
|
+
expect(tracker.getScore(0)).toBe(50);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not go below 0", () => {
|
|
36
|
+
for (let i = 0; i < 10; i++) {
|
|
37
|
+
tracker.recordFailure(0);
|
|
38
|
+
}
|
|
39
|
+
expect(tracker.getScore(0)).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("caps at max_score", () => {
|
|
43
|
+
for (let i = 0; i < 50; i++) {
|
|
44
|
+
tracker.recordSuccess(0);
|
|
45
|
+
}
|
|
46
|
+
expect(tracker.getScore(0)).toBe(100);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("isUsable returns true when score >= min_usable", () => {
|
|
50
|
+
expect(tracker.isUsable(0)).toBe(true); // 70 >= 50
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("isUsable returns false when score < min_usable", () => {
|
|
54
|
+
// 70 - 20 = 50 (still usable), need one more
|
|
55
|
+
tracker.recordFailure(0); // 50
|
|
56
|
+
expect(tracker.isUsable(0)).toBe(true);
|
|
57
|
+
tracker.recordRateLimit(0); // 40
|
|
58
|
+
expect(tracker.isUsable(0)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reset removes tracking for an account", () => {
|
|
62
|
+
tracker.recordFailure(0);
|
|
63
|
+
tracker.reset(0);
|
|
64
|
+
expect(tracker.getScore(0)).toBe(70); // Back to initial
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("tracks accounts independently", () => {
|
|
68
|
+
tracker.recordFailure(0);
|
|
69
|
+
tracker.recordSuccess(1);
|
|
70
|
+
expect(tracker.getScore(0)).toBe(50);
|
|
71
|
+
expect(tracker.getScore(1)).toBe(71);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("applies passive recovery over time", () => {
|
|
75
|
+
tracker.recordFailure(0); // Score = 50
|
|
76
|
+
// Simulate 1 hour passing
|
|
77
|
+
const state = tracker.getScore(0);
|
|
78
|
+
expect(state).toBe(50);
|
|
79
|
+
|
|
80
|
+
// We can't easily test time-based recovery without mocking Date.now
|
|
81
|
+
// but we can verify the formula works by checking the code path
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("uses custom config", () => {
|
|
85
|
+
const custom = new HealthScoreTracker({
|
|
86
|
+
initial: 100,
|
|
87
|
+
success_reward: 5,
|
|
88
|
+
rate_limit_penalty: -5,
|
|
89
|
+
failure_penalty: -10,
|
|
90
|
+
min_usable: 80,
|
|
91
|
+
max_score: 100,
|
|
92
|
+
});
|
|
93
|
+
expect(custom.getScore(0)).toBe(100);
|
|
94
|
+
custom.recordRateLimit(0);
|
|
95
|
+
expect(custom.getScore(0)).toBe(95);
|
|
96
|
+
expect(custom.isUsable(0)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// TokenBucketTracker
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe("TokenBucketTracker", () => {
|
|
105
|
+
/** @type {TokenBucketTracker} */
|
|
106
|
+
let tracker;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
tracker = new TokenBucketTracker();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns initial tokens for unknown account", () => {
|
|
113
|
+
expect(tracker.getTokens(0)).toBe(50);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("hasTokens returns true when tokens available", () => {
|
|
117
|
+
expect(tracker.hasTokens(0)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("consume reduces tokens", () => {
|
|
121
|
+
tracker.consume(0);
|
|
122
|
+
expect(tracker.getTokens(0)).toBe(49);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("consume returns false when insufficient tokens", () => {
|
|
126
|
+
// Drain all tokens
|
|
127
|
+
for (let i = 0; i < 50; i++) {
|
|
128
|
+
tracker.consume(0);
|
|
129
|
+
}
|
|
130
|
+
expect(tracker.consume(0)).toBe(false);
|
|
131
|
+
expect(tracker.hasTokens(0)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("refund adds tokens back", () => {
|
|
135
|
+
tracker.consume(0);
|
|
136
|
+
tracker.consume(0);
|
|
137
|
+
tracker.refund(0);
|
|
138
|
+
expect(tracker.getTokens(0)).toBe(49);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("refund does not exceed max", () => {
|
|
142
|
+
tracker.refund(0, 100);
|
|
143
|
+
expect(tracker.getTokens(0)).toBe(50);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("getMaxTokens returns configured max", () => {
|
|
147
|
+
expect(tracker.getMaxTokens()).toBe(50);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("uses custom config", () => {
|
|
151
|
+
const custom = new TokenBucketTracker({
|
|
152
|
+
max_tokens: 10,
|
|
153
|
+
initial_tokens: 5,
|
|
154
|
+
regeneration_rate_per_minute: 1,
|
|
155
|
+
});
|
|
156
|
+
expect(custom.getTokens(0)).toBe(5);
|
|
157
|
+
expect(custom.getMaxTokens()).toBe(10);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("tracks accounts independently", () => {
|
|
161
|
+
tracker.consume(0);
|
|
162
|
+
expect(tracker.getTokens(0)).toBe(49);
|
|
163
|
+
expect(tracker.getTokens(1)).toBe(50);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// selectAccount
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe("selectAccount", () => {
|
|
172
|
+
/** @type {HealthScoreTracker} */
|
|
173
|
+
let healthTracker;
|
|
174
|
+
/** @type {TokenBucketTracker} */
|
|
175
|
+
let tokenTracker;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
healthTracker = new HealthScoreTracker();
|
|
179
|
+
tokenTracker = new TokenBucketTracker();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {Partial<import('./rotation.mjs').AccountCandidate>} overrides
|
|
184
|
+
* @returns {import('./rotation.mjs').AccountCandidate}
|
|
185
|
+
*/
|
|
186
|
+
function makeCandidate(overrides = {}) {
|
|
187
|
+
return {
|
|
188
|
+
index: 0,
|
|
189
|
+
lastUsed: 0,
|
|
190
|
+
healthScore: 70,
|
|
191
|
+
isRateLimited: false,
|
|
192
|
+
enabled: true,
|
|
193
|
+
...overrides,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it("returns null when no candidates", () => {
|
|
198
|
+
expect(selectAccount([], "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns null when all candidates are rate-limited", () => {
|
|
202
|
+
const candidates = [
|
|
203
|
+
makeCandidate({ index: 0, isRateLimited: true }),
|
|
204
|
+
makeCandidate({ index: 1, isRateLimited: true }),
|
|
205
|
+
];
|
|
206
|
+
expect(selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns null when all candidates are disabled", () => {
|
|
210
|
+
const candidates = [makeCandidate({ index: 0, enabled: false })];
|
|
211
|
+
expect(selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Sticky strategy
|
|
215
|
+
describe("sticky strategy", () => {
|
|
216
|
+
it("returns current account if available", () => {
|
|
217
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
218
|
+
const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
|
|
219
|
+
expect(result?.index).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("switches when current is rate-limited", () => {
|
|
223
|
+
const candidates = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
|
|
224
|
+
const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
|
|
225
|
+
expect(result?.index).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does not switch when current is unhealthy", () => {
|
|
229
|
+
// Make account 0 unhealthy
|
|
230
|
+
for (let i = 0; i < 5; i++) {
|
|
231
|
+
healthTracker.recordFailure(0);
|
|
232
|
+
}
|
|
233
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
234
|
+
const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 0);
|
|
235
|
+
expect(result?.index).toBe(0);
|
|
236
|
+
expect(result?.cursor).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("keeps current even when cursor points elsewhere", () => {
|
|
240
|
+
// Make account 0 unhealthy
|
|
241
|
+
for (let i = 0; i < 5; i++) {
|
|
242
|
+
healthTracker.recordFailure(0);
|
|
243
|
+
}
|
|
244
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
245
|
+
// Even with cursor=1, sticky should keep the current account
|
|
246
|
+
const result = selectAccount(candidates, "sticky", 0, healthTracker, tokenTracker, 1);
|
|
247
|
+
expect(result?.index).toBe(0);
|
|
248
|
+
expect(result?.cursor).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("selects first available when no current", () => {
|
|
252
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
253
|
+
const result = selectAccount(candidates, "sticky", null, healthTracker, tokenTracker, 0);
|
|
254
|
+
expect(result?.index).toBe(0);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Round-robin strategy
|
|
259
|
+
describe("round-robin strategy", () => {
|
|
260
|
+
it("rotates through accounts", () => {
|
|
261
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 }), makeCandidate({ index: 2 })];
|
|
262
|
+
|
|
263
|
+
const r1 = selectAccount(candidates, "round-robin", null, healthTracker, tokenTracker, 0);
|
|
264
|
+
expect(r1?.index).toBe(0);
|
|
265
|
+
|
|
266
|
+
const r2 = selectAccount(candidates, "round-robin", 0, healthTracker, tokenTracker, r1.cursor);
|
|
267
|
+
expect(r2?.index).toBe(1);
|
|
268
|
+
|
|
269
|
+
const r3 = selectAccount(candidates, "round-robin", 1, healthTracker, tokenTracker, r2.cursor);
|
|
270
|
+
expect(r3?.index).toBe(2);
|
|
271
|
+
|
|
272
|
+
// Wraps around
|
|
273
|
+
const r4 = selectAccount(candidates, "round-robin", 2, healthTracker, tokenTracker, r3.cursor);
|
|
274
|
+
expect(r4?.index).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("skips rate-limited accounts", () => {
|
|
278
|
+
const candidates = [
|
|
279
|
+
makeCandidate({ index: 0, isRateLimited: true }),
|
|
280
|
+
makeCandidate({ index: 1 }),
|
|
281
|
+
makeCandidate({ index: 2 }),
|
|
282
|
+
];
|
|
283
|
+
const result = selectAccount(candidates, "round-robin", null, healthTracker, tokenTracker, 0);
|
|
284
|
+
expect(result?.index).toBe(1);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Hybrid strategy
|
|
289
|
+
describe("hybrid strategy", () => {
|
|
290
|
+
it("selects the account with highest composite score", () => {
|
|
291
|
+
const candidates = [
|
|
292
|
+
makeCandidate({ index: 0, lastUsed: Date.now() }),
|
|
293
|
+
makeCandidate({ index: 1, lastUsed: 0 }), // Older = more fresh
|
|
294
|
+
];
|
|
295
|
+
// Account 1 should have higher freshness score
|
|
296
|
+
const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
|
|
297
|
+
// Both have same health and tokens, but account 1 has more freshness
|
|
298
|
+
expect(result?.index).toBe(1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("applies stickiness bonus to current account", () => {
|
|
302
|
+
const candidates = [
|
|
303
|
+
makeCandidate({ index: 0, lastUsed: Date.now() }),
|
|
304
|
+
makeCandidate({ index: 1, lastUsed: Date.now() - 1000 }),
|
|
305
|
+
];
|
|
306
|
+
// Account 0 is current — stickiness bonus should keep it
|
|
307
|
+
const result = selectAccount(candidates, "hybrid", 0, healthTracker, tokenTracker, 0);
|
|
308
|
+
expect(result?.index).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("falls back to any available when all are unhealthy", () => {
|
|
312
|
+
// Make all accounts unhealthy
|
|
313
|
+
for (let i = 0; i < 10; i++) {
|
|
314
|
+
healthTracker.recordFailure(0);
|
|
315
|
+
healthTracker.recordFailure(1);
|
|
316
|
+
}
|
|
317
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
318
|
+
const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
|
|
319
|
+
// Should fall back to first available
|
|
320
|
+
expect(result).not.toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("switches when advantage exceeds threshold", () => {
|
|
324
|
+
// Make account 0 very unhealthy
|
|
325
|
+
for (let i = 0; i < 4; i++) {
|
|
326
|
+
healthTracker.recordFailure(0);
|
|
327
|
+
}
|
|
328
|
+
// Drain account 0's tokens
|
|
329
|
+
for (let i = 0; i < 50; i++) {
|
|
330
|
+
tokenTracker.consume(0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const candidates = [makeCandidate({ index: 0, lastUsed: Date.now() }), makeCandidate({ index: 1, lastUsed: 0 })];
|
|
334
|
+
const result = selectAccount(candidates, "hybrid", 0, healthTracker, tokenTracker, 0);
|
|
335
|
+
// Account 1 should win despite stickiness bonus because account 0 is very degraded
|
|
336
|
+
expect(result?.index).toBe(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Default/unknown strategy
|
|
341
|
+
it("falls back to first available for unknown strategy", () => {
|
|
342
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
343
|
+
const result = selectAccount(candidates, "unknown", null, healthTracker, tokenTracker, 0);
|
|
344
|
+
expect(result?.index).toBe(0);
|
|
345
|
+
});
|
|
346
|
+
});
|
package/lib/storage.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { getConfigDir } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} AccountStats
|
|
9
|
+
* @property {number} requests
|
|
10
|
+
* @property {number} inputTokens
|
|
11
|
+
* @property {number} outputTokens
|
|
12
|
+
* @property {number} cacheReadTokens
|
|
13
|
+
* @property {number} cacheWriteTokens
|
|
14
|
+
* @property {number} lastReset
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} AccountMetadata
|
|
19
|
+
* @property {string} id
|
|
20
|
+
* @property {string} [email]
|
|
21
|
+
* @property {string} refreshToken
|
|
22
|
+
* @property {string} [access]
|
|
23
|
+
* @property {number} [expires]
|
|
24
|
+
* @property {number} addedAt
|
|
25
|
+
* @property {number} lastUsed
|
|
26
|
+
* @property {boolean} enabled
|
|
27
|
+
* @property {Record<string, number>} rateLimitResetTimes
|
|
28
|
+
* @property {number} consecutiveFailures
|
|
29
|
+
* @property {number | null} lastFailureTime
|
|
30
|
+
* @property {string} [lastSwitchReason]
|
|
31
|
+
* @property {AccountStats} stats
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} AccountStorage
|
|
36
|
+
* @property {number} version
|
|
37
|
+
* @property {AccountMetadata[]} accounts
|
|
38
|
+
* @property {number} activeIndex
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const CURRENT_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a fresh stats object.
|
|
45
|
+
* @param {number} [now]
|
|
46
|
+
* @returns {AccountStats}
|
|
47
|
+
*/
|
|
48
|
+
export function createDefaultStats(now) {
|
|
49
|
+
return {
|
|
50
|
+
requests: 0,
|
|
51
|
+
inputTokens: 0,
|
|
52
|
+
outputTokens: 0,
|
|
53
|
+
cacheReadTokens: 0,
|
|
54
|
+
cacheWriteTokens: 0,
|
|
55
|
+
lastReset: now ?? Date.now(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate and normalise a stats object, filling in missing fields.
|
|
61
|
+
* @param {unknown} raw
|
|
62
|
+
* @param {number} now
|
|
63
|
+
* @returns {AccountStats}
|
|
64
|
+
*/
|
|
65
|
+
function validateStats(raw, now) {
|
|
66
|
+
if (!raw || typeof raw !== "object") return createDefaultStats(now);
|
|
67
|
+
const s = /** @type {Record<string, unknown>} */ (raw);
|
|
68
|
+
const safeNum = (/** @type {unknown} */ v) =>
|
|
69
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
|
70
|
+
return {
|
|
71
|
+
requests: safeNum(s.requests),
|
|
72
|
+
inputTokens: safeNum(s.inputTokens),
|
|
73
|
+
outputTokens: safeNum(s.outputTokens),
|
|
74
|
+
cacheReadTokens: safeNum(s.cacheReadTokens),
|
|
75
|
+
cacheWriteTokens: safeNum(s.cacheWriteTokens),
|
|
76
|
+
lastReset: typeof s.lastReset === "number" && Number.isFinite(s.lastReset) ? s.lastReset : now,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const GITIGNORE_ENTRIES = [".gitignore", "anthropic-accounts.json", "anthropic-accounts.json.*.tmp"];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the path to the accounts storage file.
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function getStoragePath() {
|
|
87
|
+
return join(getConfigDir(), "anthropic-accounts.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Ensure .gitignore in the config directory includes our files.
|
|
92
|
+
* @param {string} configDir
|
|
93
|
+
*/
|
|
94
|
+
export function ensureGitignore(configDir) {
|
|
95
|
+
const gitignorePath = join(configDir, ".gitignore");
|
|
96
|
+
try {
|
|
97
|
+
let content = "";
|
|
98
|
+
/** @type {string[]} */
|
|
99
|
+
let existingLines = [];
|
|
100
|
+
|
|
101
|
+
if (existsSync(gitignorePath)) {
|
|
102
|
+
content = readFileSync(gitignorePath, "utf-8");
|
|
103
|
+
existingLines = content.split("\n").map((line) => line.trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const missingEntries = GITIGNORE_ENTRIES.filter((entry) => !existingLines.includes(entry));
|
|
107
|
+
|
|
108
|
+
if (missingEntries.length === 0) return;
|
|
109
|
+
|
|
110
|
+
if (content === "") {
|
|
111
|
+
writeFileSync(gitignorePath, missingEntries.join("\n") + "\n", "utf-8");
|
|
112
|
+
} else {
|
|
113
|
+
const suffix = content.endsWith("\n") ? "" : "\n";
|
|
114
|
+
appendFileSync(gitignorePath, suffix + missingEntries.join("\n") + "\n", "utf-8");
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore gitignore errors
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Deduplicate accounts by refresh token, keeping the most recently used.
|
|
123
|
+
* @param {AccountMetadata[]} accounts
|
|
124
|
+
* @returns {AccountMetadata[]}
|
|
125
|
+
*/
|
|
126
|
+
export function deduplicateByRefreshToken(accounts) {
|
|
127
|
+
/** @type {Map<string, AccountMetadata>} */
|
|
128
|
+
const tokenMap = new Map();
|
|
129
|
+
|
|
130
|
+
for (const acc of accounts) {
|
|
131
|
+
if (!acc.refreshToken) continue;
|
|
132
|
+
const existing = tokenMap.get(acc.refreshToken);
|
|
133
|
+
if (!existing || (acc.lastUsed || 0) > (existing.lastUsed || 0)) {
|
|
134
|
+
tokenMap.set(acc.refreshToken, acc);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Array.from(tokenMap.values());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate a single account entry.
|
|
143
|
+
* @param {unknown} raw
|
|
144
|
+
* @param {number} now
|
|
145
|
+
* @returns {AccountMetadata | null}
|
|
146
|
+
*/
|
|
147
|
+
function validateAccount(raw, now) {
|
|
148
|
+
if (!raw || typeof raw !== "object") return null;
|
|
149
|
+
const acc = /** @type {Record<string, unknown>} */ (raw);
|
|
150
|
+
|
|
151
|
+
if (typeof acc.refreshToken !== "string" || !acc.refreshToken) return null;
|
|
152
|
+
|
|
153
|
+
const addedAt = typeof acc.addedAt === "number" && Number.isFinite(acc.addedAt) ? acc.addedAt : now;
|
|
154
|
+
|
|
155
|
+
const id = typeof acc.id === "string" && acc.id ? acc.id : `${addedAt}:${acc.refreshToken.slice(0, 12)}`;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
id,
|
|
159
|
+
email: typeof acc.email === "string" ? acc.email : undefined,
|
|
160
|
+
refreshToken: acc.refreshToken,
|
|
161
|
+
access: typeof acc.access === "string" ? acc.access : undefined,
|
|
162
|
+
expires: typeof acc.expires === "number" && Number.isFinite(acc.expires) ? acc.expires : undefined,
|
|
163
|
+
addedAt,
|
|
164
|
+
lastUsed: typeof acc.lastUsed === "number" && Number.isFinite(acc.lastUsed) ? acc.lastUsed : 0,
|
|
165
|
+
enabled: acc.enabled !== false,
|
|
166
|
+
rateLimitResetTimes:
|
|
167
|
+
acc.rateLimitResetTimes && typeof acc.rateLimitResetTimes === "object" && !Array.isArray(acc.rateLimitResetTimes)
|
|
168
|
+
? /** @type {Record<string, number>} */ (acc.rateLimitResetTimes)
|
|
169
|
+
: {},
|
|
170
|
+
consecutiveFailures:
|
|
171
|
+
typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
|
|
172
|
+
lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
|
|
173
|
+
lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
|
|
174
|
+
stats: validateStats(acc.stats, now),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Load accounts from disk.
|
|
180
|
+
* @returns {Promise<AccountStorage | null>}
|
|
181
|
+
*/
|
|
182
|
+
export async function loadAccounts() {
|
|
183
|
+
const storagePath = getStoragePath();
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const content = await fs.readFile(storagePath, "utf-8");
|
|
187
|
+
const data = JSON.parse(content);
|
|
188
|
+
|
|
189
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.accounts)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (data.version !== CURRENT_VERSION) {
|
|
194
|
+
// Future: handle migrations here
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const accounts = data.accounts
|
|
200
|
+
.map((raw) => validateAccount(raw, now))
|
|
201
|
+
.filter(/** @returns {acc is AccountMetadata} */ (acc) => acc !== null);
|
|
202
|
+
|
|
203
|
+
const deduped = deduplicateByRefreshToken(accounts);
|
|
204
|
+
|
|
205
|
+
let activeIndex = typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) ? data.activeIndex : 0;
|
|
206
|
+
|
|
207
|
+
if (deduped.length > 0) {
|
|
208
|
+
activeIndex = Math.max(0, Math.min(activeIndex, deduped.length - 1));
|
|
209
|
+
} else {
|
|
210
|
+
activeIndex = 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
version: CURRENT_VERSION,
|
|
215
|
+
accounts: deduped,
|
|
216
|
+
activeIndex,
|
|
217
|
+
};
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
220
|
+
if (code === "ENOENT") return null;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Save accounts to disk atomically.
|
|
227
|
+
* @param {AccountStorage} storage
|
|
228
|
+
* @returns {Promise<void>}
|
|
229
|
+
*/
|
|
230
|
+
export async function saveAccounts(storage) {
|
|
231
|
+
const storagePath = getStoragePath();
|
|
232
|
+
const configDir = dirname(storagePath);
|
|
233
|
+
|
|
234
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
235
|
+
ensureGitignore(configDir);
|
|
236
|
+
|
|
237
|
+
const tempPath = `${storagePath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
238
|
+
const content = JSON.stringify(storage, null, 2);
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
242
|
+
await fs.rename(tempPath, storagePath);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
try {
|
|
245
|
+
await fs.unlink(tempPath);
|
|
246
|
+
} catch {
|
|
247
|
+
// Ignore cleanup errors
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Clear all accounts from disk.
|
|
255
|
+
* @returns {Promise<void>}
|
|
256
|
+
*/
|
|
257
|
+
export async function clearAccounts() {
|
|
258
|
+
const storagePath = getStoragePath();
|
|
259
|
+
try {
|
|
260
|
+
await fs.unlink(storagePath);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const code = /** @type {NodeJS.ErrnoException} */ (error).code;
|
|
263
|
+
if (code !== "ENOENT") throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|