@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.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -6,95 +6,95 @@ import { HealthScoreTracker, selectAccount, TokenBucketTracker } from "./rotatio
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
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
- });
9
+ /** @type {HealthScoreTracker} */
10
+ let tracker;
41
11
 
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,
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
- /** @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)).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
- // selectAccount
169
- // ---------------------------------------------------------------------------
112
+ it("returns initial tokens for unknown account", () => {
113
+ expect(tracker.getTokens(0)).toBe(50);
114
+ });
170
115
 
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
- }
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("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);
120
+ it("consume reduces tokens", () => {
121
+ tracker.consume(0);
122
+ expect(tracker.getTokens(0)).toBeCloseTo(49, 4);
226
123
  });
227
124
 
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);
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("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);
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("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);
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
- // 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 })];
146
+ it("getMaxTokens returns configured max", () => {
147
+ expect(tracker.getMaxTokens()).toBe(50);
148
+ });
262
149
 
263
- const r1 = selectAccount(candidates, "round-robin", null, healthTracker, tokenTracker, 0);
264
- expect(r1?.index).toBe(0);
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
- const r2 = selectAccount(candidates, "round-robin", 0, healthTracker, tokenTracker, r1.cursor);
267
- expect(r2?.index).toBe(1);
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
- const r3 = selectAccount(candidates, "round-robin", 1, healthTracker, tokenTracker, r2.cursor);
270
- expect(r3?.index).toBe(2);
197
+ it("returns null when no candidates", () => {
198
+ expect(selectAccount([], "sticky", null, healthTracker, tokenTracker, 0)).toBeNull();
199
+ });
271
200
 
272
- // Wraps around
273
- const r4 = selectAccount(candidates, "round-robin", 2, healthTracker, tokenTracker, r3.cursor);
274
- expect(r4?.index).toBe(0);
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("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);
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
- // 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);
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
- 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);
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
- 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();
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
- 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);
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
  });