opencode-anthropic-fix 0.2.1 → 0.3.0

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 (39) hide show
  1. package/README.md +24 -0
  2. package/index.mjs +155 -149
  3. package/lib/config.mjs +47 -25
  4. package/lib/mimicry/adapter-input.mjs +671 -0
  5. package/lib/mimicry/context-hint-threshold.mjs +127 -0
  6. package/lib/mimicry/headers.mjs +71 -64
  7. package/lib/mimicry/request-body.mjs +23 -22
  8. package/lib/mimicry/system-prompt.mjs +54 -23
  9. package/lib/mimicry/wire-compat.mjs +305 -0
  10. package/package.json +3 -3
  11. package/lib/account-state.test.mjs +0 -109
  12. package/lib/accounts.test.mjs +0 -912
  13. package/lib/adaptive-thinking-models.test.mjs +0 -30
  14. package/lib/backoff.test.mjs +0 -411
  15. package/lib/cc-credentials.test.mjs +0 -391
  16. package/lib/config.test.mjs +0 -764
  17. package/lib/context-hint-persist.mjs +0 -68
  18. package/lib/diagnose.test.mjs +0 -122
  19. package/lib/env.test.mjs +0 -25
  20. package/lib/haiku-call.test.mjs +0 -72
  21. package/lib/message-transform.test.mjs +0 -173
  22. package/lib/mimicry/cache.test.mjs +0 -112
  23. package/lib/mimicry/headers.test.mjs +0 -50
  24. package/lib/mimicry/models.test.mjs +0 -132
  25. package/lib/mimicry/request-body.test.mjs +0 -34
  26. package/lib/mimicry/request-helpers.test.mjs +0 -155
  27. package/lib/mimicry/response-stream.test.mjs +0 -127
  28. package/lib/mimicry/system-prompt.test.mjs +0 -73
  29. package/lib/oauth.test.mjs +0 -343
  30. package/lib/redact.test.mjs +0 -154
  31. package/lib/refresh-lock.test.mjs +0 -131
  32. package/lib/request-headers.test.mjs +0 -164
  33. package/lib/retry/overload-loop.test.mjs +0 -107
  34. package/lib/rotation.test.mjs +0 -392
  35. package/lib/session-metrics.test.mjs +0 -67
  36. package/lib/storage.test.mjs +0 -696
  37. package/lib/token-economy/microcompact.test.mjs +0 -28
  38. package/lib/token-economy/transforms.test.mjs +0 -192
  39. package/lib/tuning.test.mjs +0 -89
@@ -1,164 +0,0 @@
1
- /**
2
- * Smoke tests for lib/request-headers.mjs exports.
3
- * Verifies version constants, beta shortcuts, and user-agent builder.
4
- */
5
- import { describe, it, expect, afterEach } from "vitest";
6
- import {
7
- FALLBACK_CLAUDE_CLI_VERSION,
8
- ANTHROPIC_SDK_VERSION,
9
- getSdkVersion,
10
- BETA_SHORTCUTS,
11
- EXPERIMENTAL_BETA_FLAGS,
12
- resolveBetaShortcut,
13
- buildExtendedUserAgent,
14
- } from "./request-headers.mjs";
15
-
16
- describe("request-headers constants", () => {
17
- it("FALLBACK_CLAUDE_CLI_VERSION is 2.1.195", () => {
18
- expect(FALLBACK_CLAUDE_CLI_VERSION).toBe("2.1.195");
19
- });
20
-
21
- it("ANTHROPIC_SDK_VERSION matches expected value", () => {
22
- expect(typeof ANTHROPIC_SDK_VERSION).toBe("string");
23
- expect(ANTHROPIC_SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
24
- // The wire SDK version did NOT bump for 2.1.195.
25
- expect(ANTHROPIC_SDK_VERSION).toBe("0.94.0");
26
- });
27
- });
28
-
29
- describe("getSdkVersion", () => {
30
- it("returns known SDK version for CLI version 2.1.143", () => {
31
- // 2.1.143 is in the VERSION_TO_SDK_MAP
32
- const v = getSdkVersion("2.1.143");
33
- expect(typeof v).toBe("string");
34
- expect(v).toMatch(/^\d+\.\d+\.\d+$/);
35
- });
36
-
37
- it("resolves 2.1.195 (and the 2.1.160-2.1.194 range) to 0.94.0", () => {
38
- expect(getSdkVersion("2.1.195")).toBe("0.94.0");
39
- expect(getSdkVersion("2.1.160")).toBe("0.94.0");
40
- expect(getSdkVersion("2.1.180")).toBe("0.94.0");
41
- });
42
-
43
- it("falls back to ANTHROPIC_SDK_VERSION for an unknown CLI version", () => {
44
- expect(getSdkVersion("9.99.999")).toBe(ANTHROPIC_SDK_VERSION);
45
- });
46
-
47
- it("falls back for undefined/null input", () => {
48
- expect(getSdkVersion(undefined)).toBe(ANTHROPIC_SDK_VERSION);
49
- expect(getSdkVersion(null)).toBe(ANTHROPIC_SDK_VERSION);
50
- });
51
- });
52
-
53
- describe("BETA_SHORTCUTS", () => {
54
- it("is a Map", () => {
55
- expect(BETA_SHORTCUTS).toBeInstanceOf(Map);
56
- });
57
-
58
- it("has cache-diagnosis shortcut resolving to cache-diagnosis-2026-04-07", () => {
59
- expect(BETA_SHORTCUTS.get("cache-diagnosis")).toBe("cache-diagnosis-2026-04-07");
60
- });
61
-
62
- it("has a cache-diag alias for cache-diagnosis-2026-04-07", () => {
63
- expect(BETA_SHORTCUTS.get("cache-diag")).toBe("cache-diagnosis-2026-04-07");
64
- });
65
-
66
- it("has server-side-fallback / fallback aliases for server-side-fallback-2026-06-01", () => {
67
- expect(BETA_SHORTCUTS.get("server-side-fallback")).toBe("server-side-fallback-2026-06-01");
68
- expect(BETA_SHORTCUTS.get("fallback")).toBe("server-side-fallback-2026-06-01");
69
- });
70
-
71
- it("has a fallback-credit alias for fallback-credit-2026-06-01", () => {
72
- expect(BETA_SHORTCUTS.get("fallback-credit")).toBe("fallback-credit-2026-06-01");
73
- });
74
- });
75
-
76
- describe("EXPERIMENTAL_BETA_FLAGS", () => {
77
- it("is a Set", () => {
78
- expect(EXPERIMENTAL_BETA_FLAGS).toBeInstanceOf(Set);
79
- });
80
-
81
- it("includes the 2.1.195 refusal-fallback registry betas", () => {
82
- expect(EXPERIMENTAL_BETA_FLAGS.has("server-side-fallback-2026-06-01")).toBe(true);
83
- expect(EXPERIMENTAL_BETA_FLAGS.has("fallback-credit-2026-06-01")).toBe(true);
84
- });
85
- });
86
-
87
- describe("resolveBetaShortcut", () => {
88
- it("expands cache-diagnosis shortcut", () => {
89
- expect(resolveBetaShortcut("cache-diagnosis")).toBe("cache-diagnosis-2026-04-07");
90
- });
91
-
92
- it("expands cache-diag alias", () => {
93
- expect(resolveBetaShortcut("cache-diag")).toBe("cache-diagnosis-2026-04-07");
94
- });
95
-
96
- it("expands 1m shortcut", () => {
97
- expect(resolveBetaShortcut("1m")).toBe("context-1m-2025-08-07");
98
- });
99
-
100
- it("expands fast shortcut", () => {
101
- expect(resolveBetaShortcut("fast")).toBe("fast-mode-2026-02-01");
102
- });
103
-
104
- it("expands server-side-fallback / fallback shortcuts", () => {
105
- expect(resolveBetaShortcut("server-side-fallback")).toBe("server-side-fallback-2026-06-01");
106
- expect(resolveBetaShortcut("fallback")).toBe("server-side-fallback-2026-06-01");
107
- });
108
-
109
- it("expands fallback-credit shortcut", () => {
110
- expect(resolveBetaShortcut("fallback-credit")).toBe("fallback-credit-2026-06-01");
111
- });
112
-
113
- it("returns the input unchanged when no alias matches", () => {
114
- expect(resolveBetaShortcut("context-hint-2026-04-09")).toBe("context-hint-2026-04-09");
115
- expect(resolveBetaShortcut("no-such-beta")).toBe("no-such-beta");
116
- });
117
-
118
- it("returns empty string for falsy/empty input", () => {
119
- expect(resolveBetaShortcut(undefined)).toBe("");
120
- expect(resolveBetaShortcut("")).toBe("");
121
- expect(resolveBetaShortcut(null)).toBe("");
122
- });
123
- });
124
-
125
- describe("buildExtendedUserAgent", () => {
126
- afterEach(() => {
127
- delete process.env.CLAUDE_CODE_ENTRYPOINT;
128
- delete process.env.CLAUDE_AGENT_SDK_VERSION;
129
- delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
130
- });
131
-
132
- it("returns default cli format without env vars", () => {
133
- delete process.env.CLAUDE_CODE_ENTRYPOINT;
134
- delete process.env.CLAUDE_AGENT_SDK_VERSION;
135
- delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
136
- const ua = buildExtendedUserAgent("2.1.143");
137
- expect(ua).toBe("claude-cli/2.1.143 (external, cli)");
138
- });
139
-
140
- it("uses CLAUDE_CODE_ENTRYPOINT when set", () => {
141
- process.env.CLAUDE_CODE_ENTRYPOINT = "vscode";
142
- const ua = buildExtendedUserAgent("2.1.143");
143
- expect(ua).toBe("claude-cli/2.1.143 (external, vscode)");
144
- });
145
-
146
- it("appends agent-sdk suffix when CLAUDE_AGENT_SDK_VERSION is set", () => {
147
- process.env.CLAUDE_AGENT_SDK_VERSION = "1.2.3";
148
- const ua = buildExtendedUserAgent("2.1.143");
149
- expect(ua).toContain("agent-sdk/1.2.3");
150
- expect(ua).toMatch(/^claude-cli\/2\.1\.143/);
151
- });
152
-
153
- it("appends client-app suffix when CLAUDE_AGENT_SDK_CLIENT_APP is set", () => {
154
- process.env.CLAUDE_AGENT_SDK_CLIENT_APP = "myapp";
155
- const ua = buildExtendedUserAgent("2.1.143");
156
- expect(ua).toContain("client-app/myapp");
157
- });
158
-
159
- it("uses FALLBACK_CLAUDE_CLI_VERSION when explicitly passed as the version", () => {
160
- const ua = buildExtendedUserAgent(FALLBACK_CLAUDE_CLI_VERSION);
161
- expect(ua).toContain(FALLBACK_CLAUDE_CLI_VERSION);
162
- expect(ua).toBe("claude-cli/2.1.195 (external, cli)");
163
- });
164
- });
@@ -1,107 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- computeServiceRetrySleepMs,
4
- selectFallbackModel,
5
- shouldServiceRetry,
6
- isTransientRateLimit,
7
- } from "./overload-loop.mjs";
8
- import {
9
- SERVICE_RETRY_BASE_DELAY_SEC,
10
- SERVICE_RETRY_MAX_DELAY_SEC,
11
- SERVICE_RETRY_JITTER_FRACTION,
12
- } from "../tuning.mjs";
13
-
14
- describe("overload-loop: computeServiceRetrySleepMs", () => {
15
- const noJitter = () => 0; // jitter factor = 1 (no reduction)
16
- const maxJitter = () => 1; // jitter factor = 1 - jitterFraction
17
-
18
- it("applies capped exponential backoff with zero jitter", () => {
19
- expect(computeServiceRetrySleepMs(0, {}, noJitter)).toBe(500);
20
- expect(computeServiceRetrySleepMs(1, {}, noJitter)).toBe(1000);
21
- expect(computeServiceRetrySleepMs(2, {}, noJitter)).toBe(2000);
22
- });
23
-
24
- it("caps the base delay at the configured maximum", () => {
25
- // 0.5 * 2^3 = 4s, capped to SERVICE_RETRY_MAX_DELAY_SEC (3s) -> 3000ms
26
- expect(SERVICE_RETRY_MAX_DELAY_SEC).toBe(3);
27
- expect(computeServiceRetrySleepMs(3, {}, noJitter)).toBe(3000);
28
- expect(computeServiceRetrySleepMs(10, {}, noJitter)).toBe(3000);
29
- });
30
-
31
- it("applies maximum downward jitter", () => {
32
- // n=0: base 500ms * (1 - 0.25) = 375ms
33
- expect(SERVICE_RETRY_JITTER_FRACTION).toBe(0.25);
34
- expect(computeServiceRetrySleepMs(0, {}, maxJitter)).toBe(375);
35
- });
36
-
37
- it("honors option overrides", () => {
38
- const ms = computeServiceRetrySleepMs(
39
- 1,
40
- { baseDelaySec: 1, backoffMultiplier: 3, maxDelaySec: 100, jitterFraction: 0 },
41
- maxJitter,
42
- );
43
- // 1 * 3^1 = 3s, jitter 1 - 1*0 = 1 -> 3000ms
44
- expect(ms).toBe(3000);
45
- });
46
-
47
- it("defaults to the tuning base delay for the first attempt", () => {
48
- expect(computeServiceRetrySleepMs(0, {}, noJitter)).toBe(SERVICE_RETRY_BASE_DELAY_SEC * 1000);
49
- });
50
- });
51
-
52
- describe("overload-loop: selectFallbackModel", () => {
53
- it("steps opus -> sonnet", () => {
54
- expect(selectFallbackModel("claude-opus-4-6")).toBe("claude-sonnet-4-6");
55
- expect(selectFallbackModel("claude-opus-4")).toBe("claude-sonnet-4");
56
- });
57
-
58
- it("steps sonnet -> haiku", () => {
59
- expect(selectFallbackModel("claude-sonnet-4-6")).toBe("claude-haiku-4-6");
60
- expect(selectFallbackModel("claude-sonnet-4-5")).toBe("claude-haiku-4-5");
61
- });
62
-
63
- it("returns null for haiku (bottom of the chain)", () => {
64
- expect(selectFallbackModel("claude-haiku-4-5")).toBeNull();
65
- });
66
-
67
- it("returns null for empty or unknown models", () => {
68
- expect(selectFallbackModel("")).toBeNull();
69
- expect(selectFallbackModel(null)).toBeNull();
70
- expect(selectFallbackModel(undefined)).toBeNull();
71
- expect(selectFallbackModel("gpt-4o")).toBeNull();
72
- });
73
- });
74
-
75
- describe("overload-loop: shouldServiceRetry", () => {
76
- it("retries 529/503 while under the budget", () => {
77
- expect(shouldServiceRetry(529, 0, 2)).toBe(true);
78
- expect(shouldServiceRetry(503, 1, 2)).toBe(true);
79
- });
80
-
81
- it("stops at the budget", () => {
82
- expect(shouldServiceRetry(529, 2, 2)).toBe(false);
83
- });
84
-
85
- it("ignores non-overload statuses", () => {
86
- expect(shouldServiceRetry(500, 0, 2)).toBe(false);
87
- expect(shouldServiceRetry(429, 0, 2)).toBe(false);
88
- expect(shouldServiceRetry(200, 0, 2)).toBe(false);
89
- });
90
- });
91
-
92
- describe("overload-loop: isTransientRateLimit", () => {
93
- const T = 10000;
94
- it("is true for a short-retry-after RATE_LIMIT_EXCEEDED 429", () => {
95
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", 5000, T)).toBe(true);
96
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", T, T)).toBe(true);
97
- });
98
-
99
- it("is false when any condition is not met", () => {
100
- expect(isTransientRateLimit(529, "RATE_LIMIT_EXCEEDED", 5000, T)).toBe(false);
101
- expect(isTransientRateLimit(429, "QUOTA_EXHAUSTED", 5000, T)).toBe(false);
102
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", null, T)).toBe(false);
103
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", undefined, T)).toBe(false);
104
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", 0, T)).toBe(false);
105
- expect(isTransientRateLimit(429, "RATE_LIMIT_EXCEEDED", 15000, T)).toBe(false);
106
- });
107
- });
@@ -1,392 +0,0 @@
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)).toBeCloseTo(49, 1);
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)).toBeCloseTo(49, 0);
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: Date.now() - 120_000 }), // 2 min ago = more fresh
294
- ];
295
- // Account 1 should have higher freshness score (120s * 0.1 = 12pt bonus)
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
- // Single strategy (issue #20)
341
- describe("single strategy", () => {
342
- it("selects first available account when no current index", () => {
343
- const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
344
- const result = selectAccount(candidates, "single", null, healthTracker, tokenTracker, 0);
345
- expect(result?.index).toBe(0);
346
- });
347
-
348
- it("stays on the current account when it is available", () => {
349
- const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
350
- const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
351
- expect(result?.index).toBe(0);
352
- expect(result?.cursor).toBe(0);
353
- });
354
-
355
- it("returns null when current is rate-limited (does NOT switch)", () => {
356
- const candidates = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
357
- const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
358
- // Unlike sticky, single must NOT fall through to account 1.
359
- expect(result).toBeNull();
360
- });
361
-
362
- it("returns null when the current account is gone (disabled/removed)", () => {
363
- const candidates = [makeCandidate({ index: 1 })];
364
- const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
365
- expect(result).toBeNull();
366
- });
367
-
368
- it("ignores health score and stays on current", () => {
369
- for (let i = 0; i < 5; i++) {
370
- healthTracker.recordFailure(0);
371
- }
372
- const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
373
- const result = selectAccount(candidates, "single", 0, healthTracker, tokenTracker, 0);
374
- expect(result?.index).toBe(0);
375
- });
376
-
377
- it("auto-resumes the current account once its rate-limit clears", () => {
378
- const limited = [makeCandidate({ index: 0, isRateLimited: true }), makeCandidate({ index: 1 })];
379
- expect(selectAccount(limited, "single", 0, healthTracker, tokenTracker, 0)).toBeNull();
380
- const cleared = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
381
- const result = selectAccount(cleared, "single", 0, healthTracker, tokenTracker, 0);
382
- expect(result?.index).toBe(0);
383
- });
384
- });
385
-
386
- // Default/unknown strategy
387
- it("falls back to first available for unknown strategy", () => {
388
- const candidates = [makeCandidate({ index: 0 }), makeCandidate({ index: 1 })];
389
- const result = selectAccount(candidates, "unknown", null, healthTracker, tokenTracker, 0);
390
- expect(result?.index).toBe(0);
391
- });
392
- });