opencode-anthropic-fix 0.1.31 → 0.2.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 +2 -0
- package/cli.mjs +42 -35
- package/index.mjs +399 -3591
- package/lib/config.mjs +9 -10
- package/lib/config.test.mjs +298 -1
- package/lib/diagnose.mjs +184 -0
- package/lib/diagnose.test.mjs +122 -0
- package/lib/env.mjs +19 -0
- package/lib/env.test.mjs +25 -0
- package/lib/mimicry/cache.mjs +62 -0
- package/lib/mimicry/cache.test.mjs +112 -0
- package/lib/mimicry/headers.mjs +530 -0
- package/lib/mimicry/headers.test.mjs +50 -0
- package/lib/mimicry/models.mjs +146 -0
- package/lib/mimicry/models.test.mjs +132 -0
- package/lib/mimicry/request-body.mjs +684 -0
- package/lib/mimicry/request-body.test.mjs +34 -0
- package/lib/mimicry/request-helpers.mjs +243 -0
- package/lib/mimicry/request-helpers.test.mjs +155 -0
- package/lib/mimicry/response-stream.mjs +429 -0
- package/lib/mimicry/response-stream.test.mjs +127 -0
- package/lib/mimicry/system-prompt.mjs +638 -0
- package/lib/mimicry/system-prompt.test.mjs +73 -0
- package/lib/oauth.mjs +1 -1
- package/lib/oauth.test.mjs +8 -4
- package/lib/redact.mjs +147 -0
- package/lib/redact.test.mjs +154 -0
- package/lib/refresh-lock.test.mjs +52 -0
- package/lib/retry/overload-loop.mjs +83 -0
- package/lib/retry/overload-loop.test.mjs +107 -0
- package/lib/session-metrics.mjs +75 -0
- package/lib/session-metrics.test.mjs +67 -0
- package/lib/storage.test.mjs +82 -0
- package/lib/token-economy/microcompact.mjs +35 -0
- package/lib/token-economy/microcompact.test.mjs +28 -0
- package/lib/token-economy/transforms.mjs +637 -0
- package/lib/token-economy/transforms.test.mjs +192 -0
- package/lib/tuning.mjs +70 -0
- package/lib/tuning.test.mjs +89 -0
- package/package.json +4 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildSystemPromptBlocks,
|
|
4
|
+
dedupeSystemBlocks,
|
|
5
|
+
getCacheControlForScope,
|
|
6
|
+
getCachedCCPrompt,
|
|
7
|
+
isTitleGeneratorSystemBlocks,
|
|
8
|
+
resetCachedCCPrompt,
|
|
9
|
+
sanitizeSystemText,
|
|
10
|
+
tailSystemBlock,
|
|
11
|
+
} from "./system-prompt.mjs";
|
|
12
|
+
|
|
13
|
+
describe("system prompt mimicry", () => {
|
|
14
|
+
it("sanitizes OpenCode branding with the existing case rules", () => {
|
|
15
|
+
expect(sanitizeSystemText("OpenCode opencode openCode opencode_helper")).toBe(
|
|
16
|
+
"Claude Code Claude Claude opencode_helper",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("removes duplicate normalized text blocks", () => {
|
|
21
|
+
const first = { type: "text", text: "First line\nSecond line" };
|
|
22
|
+
const duplicate = { type: "text", text: " First line \r\n Second line " };
|
|
23
|
+
const unique = { type: "text", text: "Unique" };
|
|
24
|
+
|
|
25
|
+
expect(dedupeSystemBlocks([first, duplicate, unique])).toEqual([first, unique]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("tails a system block while retaining key constraints", () => {
|
|
29
|
+
expect(tailSystemBlock("Identity\n\ndiscard this\n# Keep\nMUST remain", 200, 3)).toBe(
|
|
30
|
+
"Identity\n\n# Keep\nMUST remain\n\n[Verbose instructions trimmed after turn 3. Key constraints preserved above.]",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("detects title-generator system blocks", () => {
|
|
35
|
+
expect(isTitleGeneratorSystemBlocks([{ type: "text", text: "You are a title generator." }])).toBe(true);
|
|
36
|
+
expect(isTitleGeneratorSystemBlocks([{ type: "text", text: "You are a coding assistant." }])).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("builds cache-control wire shapes for each scope", () => {
|
|
40
|
+
const policy = { ttl: "1h", ttl_supported: true };
|
|
41
|
+
|
|
42
|
+
expect(getCacheControlForScope("global", policy)).toEqual({ type: "ephemeral", ttl: "1h", scope: "global" });
|
|
43
|
+
expect(getCacheControlForScope("org", policy)).toEqual({ type: "ephemeral", ttl: "1h" });
|
|
44
|
+
expect(getCacheControlForScope(null, policy)).toBeNull();
|
|
45
|
+
expect(getCacheControlForScope("org", { ttl: "off", ttl_supported: true })).toEqual({ type: "ephemeral" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("builds system prompt blocks from a minimal prompt and signature", () => {
|
|
49
|
+
expect(
|
|
50
|
+
buildSystemPromptBlocks([{ type: "text", text: "OpenCode assistant" }], {
|
|
51
|
+
enabled: false,
|
|
52
|
+
claudeCliVersion: "2.1.0",
|
|
53
|
+
promptCompactionMode: "off",
|
|
54
|
+
}),
|
|
55
|
+
).toEqual([{ type: "text", text: "Claude Code assistant" }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("exposes cache reset and round-trip helpers", () => {
|
|
59
|
+
resetCachedCCPrompt();
|
|
60
|
+
expect(getCachedCCPrompt()).toBeNull();
|
|
61
|
+
|
|
62
|
+
buildSystemPromptBlocks([{ type: "text", text: "You are an interactive assistant." }], {
|
|
63
|
+
enabled: true,
|
|
64
|
+
claudeCliVersion: "2.1.0",
|
|
65
|
+
promptCompactionMode: "off",
|
|
66
|
+
cachePolicy: { ttl: "1h", ttl_supported: true },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(getCachedCCPrompt()).toBe("You are an interactive assistant.");
|
|
70
|
+
resetCachedCCPrompt();
|
|
71
|
+
expect(getCachedCCPrompt()).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
package/lib/oauth.mjs
CHANGED
|
@@ -58,7 +58,7 @@ function generatePKCE() {
|
|
|
58
58
|
/**
|
|
59
59
|
* Resolve whether the SDK-native-fetch token fingerprint should be used.
|
|
60
60
|
* An explicit boolean override (used by callers/tests) wins; otherwise the
|
|
61
|
-
* live `oauth.sdk_token_useragent` config flag is read (default
|
|
61
|
+
* live `oauth.sdk_token_useragent` config flag is read (default true).
|
|
62
62
|
* @param {boolean | undefined} override
|
|
63
63
|
* @returns {boolean}
|
|
64
64
|
*/
|
package/lib/oauth.test.mjs
CHANGED
|
@@ -13,7 +13,7 @@ describe("oauth headers", () => {
|
|
|
13
13
|
vi.unstubAllGlobals();
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it("sends
|
|
16
|
+
it("sends SDK native-fetch fingerprint by default on token exchange", async () => {
|
|
17
17
|
mockFetch.mockResolvedValueOnce({
|
|
18
18
|
ok: false,
|
|
19
19
|
status: 400,
|
|
@@ -23,10 +23,12 @@ describe("oauth headers", () => {
|
|
|
23
23
|
await exchange("code#state", "verifier");
|
|
24
24
|
|
|
25
25
|
const [, init] = mockFetch.mock.calls[0];
|
|
26
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
26
|
+
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
27
|
+
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
28
|
+
expect("Accept" in init.headers).toBe(false);
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
it("sends
|
|
31
|
+
it("sends SDK native-fetch fingerprint by default on token refresh", async () => {
|
|
30
32
|
mockFetch.mockResolvedValueOnce({
|
|
31
33
|
ok: true,
|
|
32
34
|
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
@@ -35,7 +37,9 @@ describe("oauth headers", () => {
|
|
|
35
37
|
await refreshToken("refresh-token");
|
|
36
38
|
|
|
37
39
|
const [, init] = mockFetch.mock.calls[0];
|
|
38
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
40
|
+
expect(init.headers["User-Agent"]).toBe("anthropic-sdk-typescript/0.94.0 userOAuthProvider");
|
|
41
|
+
expect(init.headers["anthropic-beta"]).toBe("oauth-2025-04-20");
|
|
42
|
+
expect("Accept" in init.headers).toBe(false);
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
it("sends Claude Code user-agent on token revoke", async () => {
|
package/lib/redact.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const HASH_MASK_PATTERN = /^\[redacted sha256:[0-9a-f]{12}\]$/;
|
|
4
|
+
const AUTH_HASH_MASK_PATTERN = /^[A-Za-z][A-Za-z0-9_-]* \[redacted sha256:[0-9a-f]{12}\]$/;
|
|
5
|
+
const WHOLE_VALUE_KEYS = new Set(["cookie", "set-cookie", "x-api-key", "api-key"]);
|
|
6
|
+
const SECRET_KEYS = new Set(["access", "refreshtoken", "refresh_token", "token", "client_secret", "code", "id_token"]);
|
|
7
|
+
|
|
8
|
+
function safeString(value) {
|
|
9
|
+
try {
|
|
10
|
+
return String(value);
|
|
11
|
+
} catch {
|
|
12
|
+
return "[unprintable secret]";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hashMask(value) {
|
|
17
|
+
const stringValue = safeString(value);
|
|
18
|
+
if (HASH_MASK_PATTERN.test(stringValue)) return stringValue;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const hash = createHash("sha256").update(stringValue).digest("hex").slice(0, 12);
|
|
22
|
+
return `[redacted sha256:${hash}]`;
|
|
23
|
+
} catch {
|
|
24
|
+
return "[redacted]";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function redactAuthorization(value) {
|
|
29
|
+
const stringValue = safeString(value);
|
|
30
|
+
if (AUTH_HASH_MASK_PATTERN.test(stringValue) || HASH_MASK_PATTERN.test(stringValue)) return stringValue;
|
|
31
|
+
|
|
32
|
+
const scheme = stringValue.match(/^([A-Za-z][A-Za-z0-9_-]*)\s+/)?.[1];
|
|
33
|
+
const mask = hashMask(stringValue);
|
|
34
|
+
return scheme ? `${scheme} ${mask}` : mask;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function redactEmail(value) {
|
|
38
|
+
const stringValue = safeString(value);
|
|
39
|
+
if (/^a\*\*\*@[^@]+$/.test(stringValue)) return stringValue;
|
|
40
|
+
|
|
41
|
+
const separator = stringValue.lastIndexOf("@");
|
|
42
|
+
if (separator <= 0 || separator === stringValue.length - 1) return hashMask(stringValue);
|
|
43
|
+
return `a***${stringValue.slice(separator)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function redactValueForKey(key, value) {
|
|
47
|
+
const normalizedKey = key.toLowerCase();
|
|
48
|
+
if (normalizedKey === "authorization" || normalizedKey === "proxy-authorization") {
|
|
49
|
+
return redactAuthorization(value);
|
|
50
|
+
}
|
|
51
|
+
if (WHOLE_VALUE_KEYS.has(normalizedKey) || SECRET_KEYS.has(normalizedKey)) return hashMask(value);
|
|
52
|
+
if (normalizedKey === "email") return redactEmail(value);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return a deep-cloned, logging-safe copy of a value.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} value
|
|
60
|
+
* @returns {unknown}
|
|
61
|
+
*/
|
|
62
|
+
export function redactSecrets(value) {
|
|
63
|
+
try {
|
|
64
|
+
const seen = new WeakMap();
|
|
65
|
+
|
|
66
|
+
const clone = (current) => {
|
|
67
|
+
if (typeof current === "string") return redactString(current);
|
|
68
|
+
if (current === null || typeof current !== "object") return current;
|
|
69
|
+
if (seen.has(current)) return seen.get(current);
|
|
70
|
+
|
|
71
|
+
let output;
|
|
72
|
+
try {
|
|
73
|
+
if (current instanceof Date) return new Date(current.getTime());
|
|
74
|
+
if (current instanceof RegExp) return new RegExp(current.source, current.flags);
|
|
75
|
+
output = Array.isArray(current) ? [] : {};
|
|
76
|
+
} catch {
|
|
77
|
+
return "[unavailable object]";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
seen.set(current, output);
|
|
81
|
+
|
|
82
|
+
let keys;
|
|
83
|
+
try {
|
|
84
|
+
keys = Object.keys(current);
|
|
85
|
+
} catch {
|
|
86
|
+
return output;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
let child;
|
|
91
|
+
try {
|
|
92
|
+
child = current[key];
|
|
93
|
+
} catch {
|
|
94
|
+
child = "[unavailable value]";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const redacted = redactValueForKey(key, child);
|
|
98
|
+
const clonedValue = redacted === undefined ? clone(child) : redacted;
|
|
99
|
+
try {
|
|
100
|
+
Object.defineProperty(output, key, {
|
|
101
|
+
value: clonedValue,
|
|
102
|
+
enumerable: true,
|
|
103
|
+
configurable: true,
|
|
104
|
+
writable: true,
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// A hostile object key must not prevent the remaining values from being redacted.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return output;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return clone(value);
|
|
115
|
+
} catch {
|
|
116
|
+
return "[redaction failed]";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Redact a plain request or response header map.
|
|
122
|
+
*
|
|
123
|
+
* @param {Record<string, unknown>} headersObj
|
|
124
|
+
* @returns {unknown}
|
|
125
|
+
*/
|
|
126
|
+
export function redactHeaders(headersObj) {
|
|
127
|
+
return redactSecrets(headersObj);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Scrub token-shaped values from a free-form diagnostic string.
|
|
132
|
+
*
|
|
133
|
+
* @param {unknown} str
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function redactString(str) {
|
|
137
|
+
try {
|
|
138
|
+
return safeString(str)
|
|
139
|
+
.replace(/Bearer(\s+|%20)([A-Za-z0-9._-]+)/gi, (_match, separator, credential) => {
|
|
140
|
+
return `Bearer${separator}${hashMask(credential)}`;
|
|
141
|
+
})
|
|
142
|
+
.replace(/sk-ant-[A-Za-z0-9._-]+/gi, (token) => hashMask(token))
|
|
143
|
+
.replace(/\boat01[A-Za-z0-9._-]{8,}\b/gi, (token) => hashMask(token));
|
|
144
|
+
} catch {
|
|
145
|
+
return "[redaction failed]";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { redactHeaders, redactSecrets, redactString } from "./redact.mjs";
|
|
3
|
+
|
|
4
|
+
describe("redactSecrets", () => {
|
|
5
|
+
it("masks authorization without retaining token content", () => {
|
|
6
|
+
const token = "sk-ant-oat01-XXXX";
|
|
7
|
+
const input = { Authorization: `Bearer ${token}` };
|
|
8
|
+
const result = redactHeaders(input);
|
|
9
|
+
|
|
10
|
+
expect(result.Authorization).toMatch(/^Bearer \[redacted sha256:[0-9a-f]{12}\]$/);
|
|
11
|
+
expect(result.Authorization).not.toContain(token);
|
|
12
|
+
expect(input.Authorization).toBe(`Bearer ${token}`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("masks cookie and API key header values", () => {
|
|
16
|
+
const result = redactHeaders({ "set-cookie": "session=secret", "x-api-key": "key-secret" });
|
|
17
|
+
|
|
18
|
+
expect(result["set-cookie"]).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
19
|
+
expect(result["x-api-key"]).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
20
|
+
expect(result["set-cookie"]).not.toContain("session=secret");
|
|
21
|
+
expect(result["x-api-key"]).not.toContain("key-secret");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("masks nested account credentials", () => {
|
|
25
|
+
const input = { account: { refreshToken: "refresh-secret", access: "access-secret" } };
|
|
26
|
+
const result = redactSecrets(input);
|
|
27
|
+
|
|
28
|
+
expect(result.account.refreshToken).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
29
|
+
expect(result.account.access).toMatch(/^\[redacted sha256:[0-9a-f]{12}\]$/);
|
|
30
|
+
expect(result).not.toBe(input);
|
|
31
|
+
expect(result.account).not.toBe(input.account);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("leaves mimicry fingerprint headers byte-identical", () => {
|
|
35
|
+
const input = {
|
|
36
|
+
"anthropic-beta": "oauth-2025-04-20,prompt-caching-2024-07-31",
|
|
37
|
+
"user-agent": "claude-cli/2.1.0 (external, cli)",
|
|
38
|
+
"x-stainless-arch": "x64",
|
|
39
|
+
"anthropic-version": "2023-06-01",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
expect(redactHeaders(input)).toEqual(input);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves generic non-secret fields intact", () => {
|
|
46
|
+
const input = {
|
|
47
|
+
model: "claude-sonnet-4-5",
|
|
48
|
+
temperature: 0.7,
|
|
49
|
+
user: "alice",
|
|
50
|
+
messages: [{ role: "user", content: "hi" }],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
expect(redactSecrets(input)).toEqual(input);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("masks an email local part and preserves its domain", () => {
|
|
57
|
+
expect(redactSecrets({ email: "marco@example.com" })).toEqual({ email: "a***@example.com" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is idempotent", () => {
|
|
61
|
+
const input = {
|
|
62
|
+
authorization: "Bearer secret-token",
|
|
63
|
+
account: { refresh_token: "refresh-secret", email: "marco@example.com" },
|
|
64
|
+
};
|
|
65
|
+
const once = redactSecrets(input);
|
|
66
|
+
|
|
67
|
+
expect(redactSecrets(once)).toEqual(once);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles null, undefined, circular, and large objects without throwing", () => {
|
|
71
|
+
const circular = { token: "secret" };
|
|
72
|
+
circular.self = circular;
|
|
73
|
+
const large = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => [`key${index}`, `value${index}`]));
|
|
74
|
+
const redactedCircular = redactSecrets(circular);
|
|
75
|
+
const redactedLarge = redactSecrets(large);
|
|
76
|
+
|
|
77
|
+
expect(() => redactSecrets(null)).not.toThrow();
|
|
78
|
+
expect(() => redactSecrets(undefined)).not.toThrow();
|
|
79
|
+
expect(() => redactSecrets(circular)).not.toThrow();
|
|
80
|
+
expect(() => redactSecrets(large)).not.toThrow();
|
|
81
|
+
expect(redactedCircular.self).toBe(redactedCircular);
|
|
82
|
+
expect(Object.keys(redactedLarge)).toHaveLength(10_000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("grep-proof: no known secret survives redactSecrets over a realistic header+body fixture", () => {
|
|
86
|
+
const fakeSecrets = {
|
|
87
|
+
bearer: "sk-ant-FAKEAUTH123.def-456",
|
|
88
|
+
cookie: "session=FAKECOOKIE123",
|
|
89
|
+
apiKey: "FAKEAPIKEY123",
|
|
90
|
+
refreshToken: "FAKEREFRESH123",
|
|
91
|
+
access: "FAKEACCESS123",
|
|
92
|
+
code: "FAKECODE123",
|
|
93
|
+
email: "fake.user@example.test",
|
|
94
|
+
oauthToken: "oat01FAKEOAUTH12345",
|
|
95
|
+
};
|
|
96
|
+
const fixture = {
|
|
97
|
+
headers: {
|
|
98
|
+
authorization: `Bearer ${fakeSecrets.bearer}`,
|
|
99
|
+
cookie: fakeSecrets.cookie,
|
|
100
|
+
"x-api-key": fakeSecrets.apiKey,
|
|
101
|
+
"x-stainless-lang": "js",
|
|
102
|
+
},
|
|
103
|
+
body: {
|
|
104
|
+
account: {
|
|
105
|
+
refreshToken: fakeSecrets.refreshToken,
|
|
106
|
+
access: fakeSecrets.access,
|
|
107
|
+
code: fakeSecrets.code,
|
|
108
|
+
email: fakeSecrets.email,
|
|
109
|
+
},
|
|
110
|
+
callbackUrl: `https://auth.example.test/callback?token=${fakeSecrets.oauthToken}`,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const serialized = JSON.stringify(redactSecrets(fixture));
|
|
115
|
+
|
|
116
|
+
expect(serialized).not.toMatch(/Bearer\s+[A-Za-z0-9._-]{6,}/);
|
|
117
|
+
expect(serialized).not.toMatch(/sk-ant-[A-Za-z0-9]{6,}/);
|
|
118
|
+
expect(serialized).not.toMatch(/\boat01[A-Za-z0-9]{8,}/);
|
|
119
|
+
for (const secret of Object.values(fakeSecrets)) {
|
|
120
|
+
expect(serialized).not.toContain(secret);
|
|
121
|
+
}
|
|
122
|
+
expect(serialized).toContain('"x-stainless-lang":"js"');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("redactString", () => {
|
|
127
|
+
it("removes bearer token markers from body dumps", () => {
|
|
128
|
+
const result = redactString("prefix Bearer sk-ant-oat01-abc.def suffix");
|
|
129
|
+
|
|
130
|
+
expect(result).not.toContain("sk-ant");
|
|
131
|
+
expect(result).not.toContain("oat01");
|
|
132
|
+
expect(result).toMatch(/Bearer \[redacted sha256:[0-9a-f]{12}\]/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("redacts a bearer/sk-ant/oat01 token embedded mid-string in a URL", () => {
|
|
136
|
+
const bearerCredential = "abc.def-123";
|
|
137
|
+
const skAntToken = "sk-ant-abc123.def-456";
|
|
138
|
+
const oauthToken = "oat01ABCDEFGH12345";
|
|
139
|
+
const bearerResult = redactString(`https://api.example.com/cb?x=1#Authorization=Bearer%20${bearerCredential}`);
|
|
140
|
+
const skAntResult = redactString(`prefix ${skAntToken} suffix`);
|
|
141
|
+
const oauthResult = redactString(`https://auth.example.com/cb#token=...${oauthToken}...`);
|
|
142
|
+
|
|
143
|
+
for (const [result, rawToken] of [
|
|
144
|
+
[bearerResult, bearerCredential],
|
|
145
|
+
[skAntResult, skAntToken],
|
|
146
|
+
[oauthResult, oauthToken],
|
|
147
|
+
]) {
|
|
148
|
+
expect(result).not.toContain(rawToken);
|
|
149
|
+
expect(result).toMatch(/\[redacted sha256:[0-9a-f]{12}\]/);
|
|
150
|
+
}
|
|
151
|
+
expect(bearerResult).toContain("https://api.example.com/cb?x=1#Authorization=Bearer%20");
|
|
152
|
+
expect(oauthResult).toContain("https://auth.example.com/cb#token=...");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
|
|
6
|
+
const itPosix = process.platform === "win32" ? it.skip : it;
|
|
6
7
|
const baseDir = join(tmpdir(), `opencode-refresh-lock-test-${process.pid}`);
|
|
7
8
|
const storagePath = join(baseDir, "anthropic-accounts.json");
|
|
8
9
|
|
|
@@ -22,6 +23,57 @@ describe("refresh-lock", () => {
|
|
|
22
23
|
await fs.rm(baseDir, { recursive: true, force: true });
|
|
23
24
|
});
|
|
24
25
|
|
|
26
|
+
it("acquires the lock on the first attempt", async () => {
|
|
27
|
+
const lock = await acquireRefreshLock("acc-first-acquire");
|
|
28
|
+
|
|
29
|
+
expect(lock.acquired).toBe(true);
|
|
30
|
+
expect(lock.lockPath).toMatch(/[\\/]locks[\\/]refresh-[0-9a-f]{24}\.lock$/);
|
|
31
|
+
expect(lock.owner).toMatch(/^[0-9a-f]{24}$/);
|
|
32
|
+
expect(lock.lockInode).toEqual(expect.any(Number));
|
|
33
|
+
|
|
34
|
+
await releaseRefreshLock(lock);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("allows exactly one concurrent caller to acquire the lock", async () => {
|
|
38
|
+
const attempts = await Promise.all([
|
|
39
|
+
acquireRefreshLock("acc-concurrent", { timeoutMs: 150, backoffMs: 5, staleMs: 60_000 }),
|
|
40
|
+
acquireRefreshLock("acc-concurrent", { timeoutMs: 150, backoffMs: 5, staleMs: 60_000 }),
|
|
41
|
+
]);
|
|
42
|
+
const winners = attempts.filter((attempt) => attempt.acquired);
|
|
43
|
+
const losers = attempts.filter((attempt) => !attempt.acquired);
|
|
44
|
+
|
|
45
|
+
expect(winners).toHaveLength(1);
|
|
46
|
+
expect(losers).toEqual([{ acquired: false, lockPath: null, owner: null, lockInode: null }]);
|
|
47
|
+
|
|
48
|
+
await releaseRefreshLock(winners[0]);
|
|
49
|
+
|
|
50
|
+
const reacquired = await acquireRefreshLock("acc-concurrent", { timeoutMs: 50 });
|
|
51
|
+
expect(reacquired.acquired).toBe(true);
|
|
52
|
+
await releaseRefreshLock(reacquired);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects when opening the lock file fails with a non-contention error", async () => {
|
|
56
|
+
const error = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
57
|
+
const openSpy = vi.spyOn(fs, "open").mockRejectedValueOnce(error);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await expect(acquireRefreshLock("acc-io-failure")).rejects.toMatchObject({ code: "EACCES" });
|
|
61
|
+
expect(openSpy).toHaveBeenCalledWith(expect.stringMatching(/refresh-[0-9a-f]{24}\.lock$/), "wx", 0o600);
|
|
62
|
+
} finally {
|
|
63
|
+
openSpy.mockRestore();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
itPosix("creates the lock file with owner-only POSIX permissions", async () => {
|
|
68
|
+
const lock = await acquireRefreshLock("acc-posix-permissions");
|
|
69
|
+
|
|
70
|
+
expect(lock.acquired).toBe(true);
|
|
71
|
+
const stat = await fs.stat(lock.lockPath);
|
|
72
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
73
|
+
|
|
74
|
+
await releaseRefreshLock(lock);
|
|
75
|
+
});
|
|
76
|
+
|
|
25
77
|
it("does not release lock with mismatched owner", async () => {
|
|
26
78
|
const lock = await acquireRefreshLock("acc-1");
|
|
27
79
|
expect(lock.acquired).toBe(true);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision helpers for the service-wide retry / overload-recovery loop.
|
|
3
|
+
*
|
|
4
|
+
* The effectful retry loop itself (account switching, sleeping, toasts, body
|
|
5
|
+
* rewriting, response transformation) stays in index.mjs; this module holds the
|
|
6
|
+
* side-effect-free computations it depends on so they can be unit-tested
|
|
7
|
+
* directly. Tuning values come from lib/tuning.mjs (single source of truth).
|
|
8
|
+
*
|
|
9
|
+
* @module retry/overload-loop
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
SERVICE_RETRY_BASE_DELAY_SEC,
|
|
14
|
+
SERVICE_RETRY_BACKOFF_MULTIPLIER,
|
|
15
|
+
SERVICE_RETRY_MAX_DELAY_SEC,
|
|
16
|
+
SERVICE_RETRY_JITTER_FRACTION,
|
|
17
|
+
} from "../tuning.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Exponential backoff (capped) with downward jitter for service-wide 529/503
|
|
21
|
+
* retries. Verbatim-equivalent to the former inline formula in index.mjs.
|
|
22
|
+
*
|
|
23
|
+
* @param {number} serviceWideRetryCount - Number of service-wide retries already taken.
|
|
24
|
+
* @param {{baseDelaySec?: number, backoffMultiplier?: number, maxDelaySec?: number, jitterFraction?: number}} [opts]
|
|
25
|
+
* @param {() => number} [random] - Injectable RNG (defaults to Math.random) for deterministic tests.
|
|
26
|
+
* @returns {number} Sleep duration in milliseconds.
|
|
27
|
+
*/
|
|
28
|
+
export function computeServiceRetrySleepMs(serviceWideRetryCount, opts = {}, random = Math.random) {
|
|
29
|
+
const baseDelaySec = opts.baseDelaySec ?? SERVICE_RETRY_BASE_DELAY_SEC;
|
|
30
|
+
const multiplier = opts.backoffMultiplier ?? SERVICE_RETRY_BACKOFF_MULTIPLIER;
|
|
31
|
+
const maxDelaySec = opts.maxDelaySec ?? SERVICE_RETRY_MAX_DELAY_SEC;
|
|
32
|
+
const jitterFraction = opts.jitterFraction ?? SERVICE_RETRY_JITTER_FRACTION;
|
|
33
|
+
const baseDelay = Math.min(baseDelaySec * Math.pow(multiplier, serviceWideRetryCount), maxDelaySec);
|
|
34
|
+
const jitter = 1 - random() * jitterFraction;
|
|
35
|
+
return Math.round(baseDelay * jitter * 1000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Model fallback chain for sustained overloads: opus -> sonnet -> haiku.
|
|
40
|
+
* Verbatim-equivalent to the former inline logic in index.mjs.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} currentModel - The model id currently being requested.
|
|
43
|
+
* @returns {string|null} The next fallback model id, or null if none applies.
|
|
44
|
+
*/
|
|
45
|
+
export function selectFallbackModel(currentModel) {
|
|
46
|
+
const model = currentModel || "";
|
|
47
|
+
if (/opus-4-6|opus-4/i.test(model)) return model.replace(/opus/i, "sonnet");
|
|
48
|
+
if (/sonnet-4-6|sonnet-4/i.test(model)) return model.replace(/sonnet/i, "haiku");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a 529 (overloaded) / 503 (unavailable) response is still eligible for
|
|
54
|
+
* a service-wide sleep-and-retry (as opposed to overload-recovery / return).
|
|
55
|
+
*
|
|
56
|
+
* @param {number} status
|
|
57
|
+
* @param {number} serviceWideRetryCount
|
|
58
|
+
* @param {number} maxServiceRetries
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function shouldServiceRetry(status, serviceWideRetryCount, maxServiceRetries) {
|
|
62
|
+
return (status === 529 || status === 503) && serviceWideRetryCount < maxServiceRetries;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a 429 is a transient burst-throttle (short retry-after) that should be
|
|
67
|
+
* retried on the SAME account rather than rotating the account pool.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} status
|
|
70
|
+
* @param {string|null|undefined} reason - Parsed rate-limit reason.
|
|
71
|
+
* @param {number|null|undefined} retryAfterMs - Parsed retry-after in ms.
|
|
72
|
+
* @param {number} thresholdMs - Upper bound below which a 429 counts as transient.
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isTransientRateLimit(status, reason, retryAfterMs, thresholdMs) {
|
|
76
|
+
return (
|
|
77
|
+
status === 429 &&
|
|
78
|
+
reason === "RATE_LIMIT_EXCEEDED" &&
|
|
79
|
+
retryAfterMs != null &&
|
|
80
|
+
retryAfterMs > 0 &&
|
|
81
|
+
retryAfterMs <= thresholdMs
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
});
|