@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/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
- try {
522
- response = await fetchWithTransport(fetchInput, {
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: requestHeaders,
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(retryUrl, {
600
- ...requestInit,
601
- body: retryBody,
602
- headers: headersForRetry,
603
- });
627
+ return fetchWithTransport(
628
+ retryUrl,
629
+ buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
630
+ );
604
631
  },
605
632
  { maxRetries: 2 },
606
633
  );
@@ -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
- // Replace lock file with a new inode that reuses owner text.
107
- await fs.unlink(firstLockPath);
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(first);
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
 
@@ -20,7 +20,7 @@ export interface RefreshLockResult {
20
20
  acquired: boolean;
21
21
  lockPath: string | null;
22
22
  owner: string | null;
23
- lockInode: number | null;
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?: number | null;
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 || null : null;
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
- // The cch billing hash mixes Date.now() into its input (src/headers/billing.ts)
130
- // to mimic CC's per-request attestation. Freeze the clock so the two calls
131
- // produce byte-identical output and this test stays about clone-safety, not
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
- // Same Date.now()-in-cch flake as the clone-safety test above. Freeze the
160
- // clock so two transformRequestBody calls on the same body produce
161
- // byte-identical output. See src/headers/billing.ts:59 for why the hash
162
- // is time-mixed, and the clone-safety test above for the full rationale.
163
- vi.useFakeTimers();
164
- vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
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
 
@@ -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
  });
@@ -1,4 +1,9 @@
1
- import { parseRetryAfterHeader, parseRetryAfterMsHeader, parseShouldRetryHeader } from "../backoff.js";
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
- config: Partial<RetryConfig> = {},
49
+ doFetch: (context: RetryAttemptContext) => Promise<Response>,
50
+ options: RetryOptions = {},
36
51
  ): Promise<Response> {
37
- const resolvedConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...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
- const response = await doFetch();
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
- const shouldRetryHeader = parseShouldRetryHeader(response);
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 billing-specific gaps and attestation notes.
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 {