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.
- package/README.md +24 -0
- package/index.mjs +155 -149
- package/lib/config.mjs +47 -25
- package/lib/mimicry/adapter-input.mjs +671 -0
- package/lib/mimicry/context-hint-threshold.mjs +127 -0
- package/lib/mimicry/headers.mjs +71 -64
- package/lib/mimicry/request-body.mjs +23 -22
- package/lib/mimicry/system-prompt.mjs +54 -23
- package/lib/mimicry/wire-compat.mjs +305 -0
- package/package.json +3 -3
- package/lib/account-state.test.mjs +0 -109
- package/lib/accounts.test.mjs +0 -912
- package/lib/adaptive-thinking-models.test.mjs +0 -30
- package/lib/backoff.test.mjs +0 -411
- package/lib/cc-credentials.test.mjs +0 -391
- package/lib/config.test.mjs +0 -764
- package/lib/context-hint-persist.mjs +0 -68
- package/lib/diagnose.test.mjs +0 -122
- package/lib/env.test.mjs +0 -25
- package/lib/haiku-call.test.mjs +0 -72
- package/lib/message-transform.test.mjs +0 -173
- package/lib/mimicry/cache.test.mjs +0 -112
- package/lib/mimicry/headers.test.mjs +0 -50
- package/lib/mimicry/models.test.mjs +0 -132
- package/lib/mimicry/request-body.test.mjs +0 -34
- package/lib/mimicry/request-helpers.test.mjs +0 -155
- package/lib/mimicry/response-stream.test.mjs +0 -127
- package/lib/mimicry/system-prompt.test.mjs +0 -73
- package/lib/oauth.test.mjs +0 -343
- package/lib/redact.test.mjs +0 -154
- package/lib/refresh-lock.test.mjs +0 -131
- package/lib/request-headers.test.mjs +0 -164
- package/lib/retry/overload-loop.test.mjs +0 -107
- package/lib/rotation.test.mjs +0 -392
- package/lib/session-metrics.test.mjs +0 -67
- package/lib/storage.test.mjs +0 -696
- package/lib/token-economy/microcompact.test.mjs +0 -28
- package/lib/token-economy/transforms.test.mjs +0 -192
- 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
|
-
});
|
package/lib/rotation.test.mjs
DELETED
|
@@ -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
|
-
});
|