@vacbo/opencode-anthropic-fix 0.1.4 → 0.1.6
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/dist/opencode-anthropic-auth-plugin.js +258 -46
- package/package.json +3 -1
- package/src/__tests__/cc-comparison.test.ts +1 -1
- package/src/__tests__/cch-drift-checker.test.ts +61 -0
- package/src/__tests__/cch-native-style.test.ts +76 -0
- package/src/__tests__/fingerprint-regression.test.ts +2 -3
- package/src/backoff.test.ts +25 -0
- package/src/backoff.ts +83 -0
- package/src/bun-fetch.test.ts +103 -0
- package/src/bun-fetch.ts +42 -10
- package/src/bun-proxy.test.ts +37 -0
- package/src/bun-proxy.ts +11 -3
- package/src/drift/cch-constants.ts +133 -0
- package/src/headers/billing.ts +6 -33
- package/src/headers/cch.ts +120 -0
- package/src/index.ts +38 -11
- package/src/refresh-lock.test.ts +11 -3
- package/src/refresh-lock.ts +7 -7
- package/src/request/body.history.test.ts +9 -31
- package/src/request/body.ts +2 -1
- package/src/request/retry.test.ts +27 -0
- package/src/request/retry.ts +44 -9
- package/src/system-prompt/builder.ts +2 -1
package/src/index.ts
CHANGED
|
@@ -518,12 +518,40 @@ export async function AnthropicAuthPlugin({
|
|
|
518
518
|
|
|
519
519
|
let response: Response;
|
|
520
520
|
const fetchInput = requestInput as string | URL | Request;
|
|
521
|
-
|
|
522
|
-
|
|
521
|
+
const buildTransportRequestInit = (
|
|
522
|
+
headers: Headers,
|
|
523
|
+
requestBody: RequestInit["body"],
|
|
524
|
+
forceFreshConnection: boolean,
|
|
525
|
+
): RequestInit => {
|
|
526
|
+
const requestHeadersForTransport = new Headers(headers);
|
|
527
|
+
if (forceFreshConnection) {
|
|
528
|
+
requestHeadersForTransport.set("connection", "close");
|
|
529
|
+
requestHeadersForTransport.set("x-proxy-disable-keepalive", "true");
|
|
530
|
+
} else {
|
|
531
|
+
requestHeadersForTransport.delete("connection");
|
|
532
|
+
requestHeadersForTransport.delete("x-proxy-disable-keepalive");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
523
536
|
...requestInit,
|
|
524
|
-
body,
|
|
525
|
-
headers:
|
|
526
|
-
|
|
537
|
+
body: requestBody,
|
|
538
|
+
headers: requestHeadersForTransport,
|
|
539
|
+
...(forceFreshConnection ? { keepalive: false } : {}),
|
|
540
|
+
};
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
response = await fetchWithRetry(
|
|
545
|
+
async ({ forceFreshConnection }) =>
|
|
546
|
+
fetchWithTransport(
|
|
547
|
+
fetchInput,
|
|
548
|
+
buildTransportRequestInit(requestHeaders, body, forceFreshConnection),
|
|
549
|
+
),
|
|
550
|
+
{
|
|
551
|
+
maxRetries: 2,
|
|
552
|
+
shouldRetryResponse: () => false,
|
|
553
|
+
},
|
|
554
|
+
);
|
|
527
555
|
} catch (err) {
|
|
528
556
|
const fetchError = err instanceof Error ? err : new Error(String(err));
|
|
529
557
|
if (accountManager && account) {
|
|
@@ -582,7 +610,7 @@ export async function AnthropicAuthPlugin({
|
|
|
582
610
|
|
|
583
611
|
let retryCount = 0;
|
|
584
612
|
const retried = await fetchWithRetry(
|
|
585
|
-
async () => {
|
|
613
|
+
async ({ forceFreshConnection }) => {
|
|
586
614
|
if (retryCount === 0) {
|
|
587
615
|
retryCount += 1;
|
|
588
616
|
return response;
|
|
@@ -596,11 +624,10 @@ export async function AnthropicAuthPlugin({
|
|
|
596
624
|
requestContext.preparedBody === undefined
|
|
597
625
|
? undefined
|
|
598
626
|
: cloneBodyForRetry(requestContext.preparedBody);
|
|
599
|
-
return fetchWithTransport(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
});
|
|
627
|
+
return fetchWithTransport(
|
|
628
|
+
retryUrl,
|
|
629
|
+
buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
|
|
630
|
+
);
|
|
604
631
|
},
|
|
605
632
|
{ maxRetries: 2 },
|
|
606
633
|
);
|
package/src/refresh-lock.test.ts
CHANGED
|
@@ -102,15 +102,23 @@ describe("refresh-lock", () => {
|
|
|
102
102
|
const first = await acquireRefreshLock("acc-4");
|
|
103
103
|
expect(first.acquired).toBe(true);
|
|
104
104
|
const firstLockPath = first.lockPath!;
|
|
105
|
+
const originalLockInode = first.lockInode;
|
|
106
|
+
expect(originalLockInode).not.toBeNull();
|
|
105
107
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
+
// Keep the same owner text but pass a deliberately mismatched inode. This
|
|
109
|
+
// tests the safety check directly without relying on filesystem-specific
|
|
110
|
+
// inode allocation behavior, which can aggressively recycle inode numbers
|
|
111
|
+
// on Linux CI runners.
|
|
108
112
|
await fs.writeFile(firstLockPath, JSON.stringify({ owner: first.owner, createdAt: Date.now() }), {
|
|
109
113
|
encoding: "utf-8",
|
|
110
114
|
mode: 0o600,
|
|
111
115
|
});
|
|
112
116
|
|
|
113
|
-
await releaseRefreshLock(
|
|
117
|
+
await releaseRefreshLock({
|
|
118
|
+
lockPath: firstLockPath,
|
|
119
|
+
owner: first.owner,
|
|
120
|
+
lockInode: originalLockInode! + 1n,
|
|
121
|
+
});
|
|
114
122
|
|
|
115
123
|
await expect(fs.stat(firstLockPath)).resolves.toBeTruthy();
|
|
116
124
|
|
package/src/refresh-lock.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface RefreshLockResult {
|
|
|
20
20
|
acquired: boolean;
|
|
21
21
|
lockPath: string | null;
|
|
22
22
|
owner: string | null;
|
|
23
|
-
lockInode:
|
|
23
|
+
lockInode: bigint | null;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface AcquireLockOptions {
|
|
@@ -51,7 +51,7 @@ export async function acquireRefreshLock(
|
|
|
51
51
|
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
52
52
|
try {
|
|
53
53
|
await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), owner }), "utf-8");
|
|
54
|
-
const stat = await handle.stat();
|
|
54
|
+
const stat = await handle.stat({ bigint: true });
|
|
55
55
|
return { acquired: true, lockPath, owner, lockInode: stat.ino };
|
|
56
56
|
} finally {
|
|
57
57
|
await handle.close();
|
|
@@ -63,8 +63,8 @@ export async function acquireRefreshLock(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
const stat = await fs.stat(lockPath);
|
|
67
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
66
|
+
const stat = await fs.stat(lockPath, { bigint: true });
|
|
67
|
+
if (Date.now() - Number(stat.mtimeMs) > staleMs) {
|
|
68
68
|
await fs.unlink(lockPath);
|
|
69
69
|
continue;
|
|
70
70
|
}
|
|
@@ -86,7 +86,7 @@ export type ReleaseLockInput =
|
|
|
86
86
|
| {
|
|
87
87
|
lockPath: string | null;
|
|
88
88
|
owner?: string | null;
|
|
89
|
-
lockInode?:
|
|
89
|
+
lockInode?: bigint | null;
|
|
90
90
|
}
|
|
91
91
|
| string
|
|
92
92
|
| null;
|
|
@@ -97,7 +97,7 @@ export type ReleaseLockInput =
|
|
|
97
97
|
export async function releaseRefreshLock(lock: ReleaseLockInput): Promise<void> {
|
|
98
98
|
const lockPath = typeof lock === "string" || lock === null ? lock : lock.lockPath;
|
|
99
99
|
const owner = typeof lock === "object" && lock ? lock.owner || null : null;
|
|
100
|
-
const lockInode = typeof lock === "object" && lock ? lock.lockInode
|
|
100
|
+
const lockInode = typeof lock === "object" && lock ? (lock.lockInode ?? null) : null;
|
|
101
101
|
|
|
102
102
|
if (!lockPath) return;
|
|
103
103
|
|
|
@@ -112,7 +112,7 @@ export async function releaseRefreshLock(lock: ReleaseLockInput): Promise<void>
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
if (lockInode) {
|
|
115
|
-
const stat = await fs.stat(lockPath);
|
|
115
|
+
const stat = await fs.stat(lockPath, { bigint: true });
|
|
116
116
|
if (stat.ino !== lockInode) {
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
@@ -126,21 +126,9 @@ describe("transformRequestBody - body cloning for retries", () => {
|
|
|
126
126
|
tools: [{ name: "read_file", description: "Read a file" }],
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// about an accidental millisecond collision. Without this, the idempotency
|
|
133
|
-
// assertion flakes whenever the two calls cross a millisecond boundary under
|
|
134
|
-
// load (husky pre-push, CI workers, etc.).
|
|
135
|
-
vi.useFakeTimers();
|
|
136
|
-
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
137
|
-
try {
|
|
138
|
-
const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
139
|
-
const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
140
|
-
expect(result1).toBe(result2);
|
|
141
|
-
} finally {
|
|
142
|
-
vi.useRealTimers();
|
|
143
|
-
}
|
|
129
|
+
const result1 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
130
|
+
const result2 = transformRequestBody(originalBody, mockSignature, mockRuntime);
|
|
131
|
+
expect(result1).toBe(result2);
|
|
144
132
|
|
|
145
133
|
const parsedOriginal = JSON.parse(originalBody);
|
|
146
134
|
expect(parsedOriginal.tools[0].name).toBe("read_file");
|
|
@@ -156,22 +144,12 @@ describe("transformRequestBody - body cloning for retries", () => {
|
|
|
156
144
|
messages: [{ role: "user", content: "test" }],
|
|
157
145
|
});
|
|
158
146
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
const result1 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
167
|
-
expect(result1).toBeDefined();
|
|
168
|
-
|
|
169
|
-
const result2 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
170
|
-
expect(result2).toBeDefined();
|
|
171
|
-
expect(result1).toBe(result2);
|
|
172
|
-
} finally {
|
|
173
|
-
vi.useRealTimers();
|
|
174
|
-
}
|
|
147
|
+
const result1 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
148
|
+
expect(result1).toBeDefined();
|
|
149
|
+
|
|
150
|
+
const result2 = transformRequestBody(body, mockSignature, mockRuntime);
|
|
151
|
+
expect(result2).toBeDefined();
|
|
152
|
+
expect(result1).toBe(result2);
|
|
175
153
|
});
|
|
176
154
|
});
|
|
177
155
|
|
package/src/request/body.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
5
|
import { CLAUDE_CODE_IDENTITY_STRING, KNOWN_IDENTITY_STRINGS } from "../constants.js";
|
|
6
|
+
import { replaceNativeStyleCch } from "../headers/cch.js";
|
|
6
7
|
import { buildSystemPromptBlocks } from "../system-prompt/builder.js";
|
|
7
8
|
import { normalizeSystemTextBlocks } from "../system-prompt/normalize.js";
|
|
8
9
|
import { normalizeThinkingBlock } from "../thinking.js";
|
|
@@ -319,7 +320,7 @@ export function transformRequestBody(
|
|
|
319
320
|
return msg;
|
|
320
321
|
});
|
|
321
322
|
}
|
|
322
|
-
return JSON.stringify(parsed);
|
|
323
|
+
return replaceNativeStyleCch(JSON.stringify(parsed));
|
|
323
324
|
} catch (err) {
|
|
324
325
|
if (err instanceof SyntaxError) {
|
|
325
326
|
debugLog?.("body parse failed:", err.message);
|
|
@@ -149,4 +149,31 @@ describe("fetchWithRetry", () => {
|
|
|
149
149
|
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
150
150
|
expect(elapsedMs).toBeGreaterThanOrEqual(2900);
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it("retries thrown retryable network errors and marks the next attempt as fresh-connection", async () => {
|
|
154
|
+
const forceFreshConnectionByAttempt: boolean[] = [];
|
|
155
|
+
const doFetch = vi.fn(async ({ forceFreshConnection = false }: { forceFreshConnection?: boolean } = {}) => {
|
|
156
|
+
forceFreshConnectionByAttempt.push(forceFreshConnection);
|
|
157
|
+
if (forceFreshConnectionByAttempt.length === 1) {
|
|
158
|
+
throw Object.assign(new Error("Connection reset by server"), { code: "ECONNRESET" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return makeResponse(200);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const response = await fetchWithRetry(doFetch, FAST_RETRY_CONFIG);
|
|
165
|
+
|
|
166
|
+
expect(response.status).toBe(200);
|
|
167
|
+
expect(doFetch).toHaveBeenCalledTimes(2);
|
|
168
|
+
expect(forceFreshConnectionByAttempt).toEqual([false, true]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("does not retry user abort errors", async () => {
|
|
172
|
+
const doFetch = vi.fn(async () => {
|
|
173
|
+
throw new DOMException("The operation was aborted", "AbortError");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await expect(fetchWithRetry(doFetch, FAST_RETRY_CONFIG)).rejects.toThrow(/aborted/i);
|
|
177
|
+
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
152
179
|
});
|
package/src/request/retry.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isRetriableNetworkError,
|
|
3
|
+
parseRetryAfterHeader,
|
|
4
|
+
parseRetryAfterMsHeader,
|
|
5
|
+
parseShouldRetryHeader,
|
|
6
|
+
} from "../backoff.js";
|
|
2
7
|
|
|
3
8
|
export interface RetryConfig {
|
|
4
9
|
maxRetries: number;
|
|
@@ -7,6 +12,16 @@ export interface RetryConfig {
|
|
|
7
12
|
jitterFraction: number;
|
|
8
13
|
}
|
|
9
14
|
|
|
15
|
+
export interface RetryAttemptContext {
|
|
16
|
+
attempt: number;
|
|
17
|
+
forceFreshConnection: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RetryOptions extends Partial<RetryConfig> {
|
|
21
|
+
shouldRetryError?: (error: unknown) => boolean;
|
|
22
|
+
shouldRetryResponse?: (response: Response) => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
11
26
|
maxRetries: 2,
|
|
12
27
|
initialDelayMs: 500,
|
|
@@ -31,22 +46,41 @@ export function shouldRetryStatus(status: number, shouldRetryHeader: boolean | n
|
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export async function fetchWithRetry(
|
|
34
|
-
doFetch: () => Promise<Response>,
|
|
35
|
-
|
|
49
|
+
doFetch: (context: RetryAttemptContext) => Promise<Response>,
|
|
50
|
+
options: RetryOptions = {},
|
|
36
51
|
): Promise<Response> {
|
|
37
|
-
const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...
|
|
52
|
+
const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...options };
|
|
53
|
+
const shouldRetryError = options.shouldRetryError ?? isRetriableNetworkError;
|
|
54
|
+
const shouldRetryResponse =
|
|
55
|
+
options.shouldRetryResponse ??
|
|
56
|
+
((response: Response) => {
|
|
57
|
+
const shouldRetryHeader = parseShouldRetryHeader(response);
|
|
58
|
+
return shouldRetryStatus(response.status, shouldRetryHeader);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let forceFreshConnection = false;
|
|
38
62
|
|
|
39
63
|
for (let attempt = 0; ; attempt++) {
|
|
40
|
-
|
|
64
|
+
let response: Response;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
response = await doFetch({ attempt, forceFreshConnection });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!shouldRetryError(error) || attempt >= resolvedConfig.maxRetries) {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const delayMs = calculateRetryDelay(attempt, resolvedConfig);
|
|
74
|
+
await waitFor(delayMs);
|
|
75
|
+
forceFreshConnection = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
41
78
|
|
|
42
79
|
if (response.ok) {
|
|
43
80
|
return response;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
const shouldRetry = shouldRetryStatus(response.status, shouldRetryHeader);
|
|
48
|
-
|
|
49
|
-
if (!shouldRetry || attempt >= resolvedConfig.maxRetries) {
|
|
83
|
+
if (!shouldRetryResponse(response) || attempt >= resolvedConfig.maxRetries) {
|
|
50
84
|
return response;
|
|
51
85
|
}
|
|
52
86
|
|
|
@@ -56,5 +90,6 @@ export async function fetchWithRetry(
|
|
|
56
90
|
calculateRetryDelay(attempt, resolvedConfig);
|
|
57
91
|
|
|
58
92
|
await waitFor(delayMs);
|
|
93
|
+
forceFreshConnection = false;
|
|
59
94
|
}
|
|
60
95
|
}
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
// - CC tool naming conventions can evolve independently from this file. Current
|
|
30
30
|
// plugin-specific tool prefix notes live in body/request docs, not here.
|
|
31
31
|
//
|
|
32
|
-
// See src/headers/billing.ts for
|
|
32
|
+
// See src/headers/billing.ts for version-suffix derivation and src/headers/cch.ts
|
|
33
|
+
// for placeholder replacement and native-style cch computation.
|
|
33
34
|
// ===========================================================================
|
|
34
35
|
|
|
35
36
|
import {
|