@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
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/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
package/src/rotation.test.ts
CHANGED
|
@@ -6,95 +6,95 @@ import { HealthScoreTracker, selectAccount, TokenBucketTracker } from "./rotatio
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
8
|
describe("HealthScoreTracker", () => {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
});
|
|
9
|
+
/** @type {HealthScoreTracker} */
|
|
10
|
+
let tracker;
|
|
41
11
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
92
97
|
});
|
|
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
98
|
});
|
|
99
99
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
@@ -102,245 +102,248 @@ describe("HealthScoreTracker", () => {
|
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
103
|
|
|
104
104
|
describe("TokenBucketTracker", () => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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)).toBeCloseTo(49, 4);
|
|
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,
|
|
105
|
+
/** @type {TokenBucketTracker} */
|
|
106
|
+
let tracker;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
tracker = new TokenBucketTracker();
|
|
155
110
|
});
|
|
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
111
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
112
|
+
it("returns initial tokens for unknown account", () => {
|
|
113
|
+
expect(tracker.getTokens(0)).toBe(50);
|
|
114
|
+
});
|
|
170
115
|
|
|
171
|
-
|
|
172
|
-
|
|
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.js').AccountCandidate>} overrides
|
|
184
|
-
* @returns {import('./rotation.js').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);
|
|
116
|
+
it("hasTokens returns true when tokens available", () => {
|
|
117
|
+
expect(tracker.hasTokens(0)).toBe(true);
|
|
220
118
|
});
|
|
221
119
|
|
|
222
|
-
it("
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
expect(result?.index).toBe(1);
|
|
120
|
+
it("consume reduces tokens", () => {
|
|
121
|
+
tracker.consume(0);
|
|
122
|
+
expect(tracker.getTokens(0)).toBeCloseTo(49, 4);
|
|
226
123
|
});
|
|
227
124
|
|
|
228
|
-
it("
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
expect(result?.index).toBe(0);
|
|
236
|
-
expect(result?.cursor).toBe(0);
|
|
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);
|
|
237
132
|
});
|
|
238
133
|
|
|
239
|
-
it("
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
|
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);
|
|
249
139
|
});
|
|
250
140
|
|
|
251
|
-
it("
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
expect(result?.index).toBe(0);
|
|
141
|
+
it("refund does not exceed max", () => {
|
|
142
|
+
tracker.refund(0, 100);
|
|
143
|
+
expect(tracker.getTokens(0)).toBe(50);
|
|
255
144
|
});
|
|
256
|
-
});
|
|
257
145
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 }), makeCandidate({ index: 2 })];
|
|
146
|
+
it("getMaxTokens returns configured max", () => {
|
|
147
|
+
expect(tracker.getMaxTokens()).toBe(50);
|
|
148
|
+
});
|
|
262
149
|
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
});
|
|
265
159
|
|
|
266
|
-
|
|
267
|
-
|
|
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.js').AccountCandidate>} overrides
|
|
184
|
+
* @returns {import('./rotation.js').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
|
+
}
|
|
268
196
|
|
|
269
|
-
|
|
270
|
-
|
|
197
|
+
it("returns null when no candidates", () => {
|
|
198
|
+
expect(selectAccount([], "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
|
|
199
|
+
});
|
|
271
200
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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();
|
|
275
207
|
});
|
|
276
208
|
|
|
277
|
-
it("
|
|
278
|
-
|
|
279
|
-
|
|
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);
|
|
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();
|
|
285
212
|
});
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
});
|
|
299
256
|
});
|
|
300
257
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
});
|
|
309
286
|
});
|
|
310
287
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 = [
|
|
334
|
+
makeCandidate({ index: 0, lastUsed: Date.now() }),
|
|
335
|
+
makeCandidate({ index: 1, lastUsed: 0 }),
|
|
336
|
+
];
|
|
337
|
+
const result = selectAccount(candidates, "hybrid", 0, healthTracker, tokenTracker, 0);
|
|
338
|
+
// Account 1 should win despite stickiness bonus because account 0 is very degraded
|
|
339
|
+
expect(result?.index).toBe(1);
|
|
340
|
+
});
|
|
321
341
|
});
|
|
322
342
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
healthTracker
|
|
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);
|
|
343
|
+
// Default/unknown strategy
|
|
344
|
+
it("falls back to first available for unknown strategy", () => {
|
|
345
|
+
const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
|
|
346
|
+
const result = selectAccount(candidates, "unknown", null, healthTracker, tokenTracker, 0);
|
|
347
|
+
expect(result?.index).toBe(0);
|
|
337
348
|
});
|
|
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
349
|
});
|