@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +19 -0
  2. package/dist/bun-proxy.mjs +282 -55
  3. package/dist/opencode-anthropic-auth-cli.mjs +194 -55
  4. package/dist/opencode-anthropic-auth-plugin.js +1816 -594
  5. package/package.json +1 -1
  6. package/src/__tests__/billing-edge-cases.test.ts +84 -0
  7. package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
  8. package/src/__tests__/debug-gating.test.ts +76 -0
  9. package/src/__tests__/decomposition-smoke.test.ts +92 -0
  10. package/src/__tests__/fingerprint-regression.test.ts +1 -1
  11. package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
  12. package/src/__tests__/helpers/conversation-history.ts +376 -0
  13. package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
  14. package/src/__tests__/helpers/deferred.ts +122 -0
  15. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
  16. package/src/__tests__/helpers/in-memory-storage.ts +152 -0
  17. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
  18. package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
  19. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
  20. package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
  21. package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
  22. package/src/__tests__/helpers/sse.ts +288 -0
  23. package/src/__tests__/index.parallel.test.ts +711 -0
  24. package/src/__tests__/sanitization-regex.test.ts +65 -0
  25. package/src/__tests__/state-bounds.test.ts +110 -0
  26. package/src/account-identity.test.ts +213 -0
  27. package/src/account-identity.ts +108 -0
  28. package/src/accounts.dedup.test.ts +696 -0
  29. package/src/accounts.test.ts +2 -1
  30. package/src/accounts.ts +485 -191
  31. package/src/bun-fetch.test.ts +379 -0
  32. package/src/bun-fetch.ts +447 -174
  33. package/src/bun-proxy.ts +289 -57
  34. package/src/circuit-breaker.test.ts +274 -0
  35. package/src/circuit-breaker.ts +235 -0
  36. package/src/cli.test.ts +1 -0
  37. package/src/cli.ts +37 -18
  38. package/src/commands/router.ts +25 -5
  39. package/src/env.ts +1 -0
  40. package/src/headers/billing.ts +31 -13
  41. package/src/index.ts +224 -247
  42. package/src/oauth.ts +7 -1
  43. package/src/parent-pid-watcher.test.ts +219 -0
  44. package/src/parent-pid-watcher.ts +99 -0
  45. package/src/plugin-helpers.ts +112 -0
  46. package/src/refresh-helpers.ts +169 -0
  47. package/src/refresh-lock.test.ts +36 -9
  48. package/src/refresh-lock.ts +2 -2
  49. package/src/request/body.history.test.ts +398 -0
  50. package/src/request/body.ts +200 -13
  51. package/src/request/metadata.ts +6 -2
  52. package/src/response/index.ts +1 -1
  53. package/src/response/mcp.ts +60 -31
  54. package/src/response/streaming.test.ts +382 -0
  55. package/src/response/streaming.ts +403 -76
  56. package/src/storage.test.ts +127 -104
  57. package/src/storage.ts +152 -62
  58. package/src/system-prompt/builder.ts +33 -3
  59. package/src/system-prompt/sanitize.ts +12 -2
  60. package/src/token-refresh.test.ts +84 -1
  61. package/src/token-refresh.ts +14 -8
package/src/bun-fetch.ts CHANGED
@@ -1,235 +1,508 @@
1
- // ---------------------------------------------------------------------------
2
- // Bun TLS proxy manager — spawns a single Bun subprocess for BoringSSL TLS.
3
- // Hardened: health checks, auto-restart, single-instance guarantee.
4
- // ---------------------------------------------------------------------------
5
-
6
- import { execFileSync, spawn } from "node:child_process";
7
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync } from "node:fs";
8
3
  import { dirname, join } from "node:path";
9
- import { tmpdir } from "node:os";
4
+ import * as readline from "node:readline";
10
5
  import { fileURLToPath } from "node:url";
11
6
 
12
- let proxyPort: number | null = null;
13
- let proxyProcess: ReturnType<typeof spawn> | null = null;
14
- let starting: Promise<number | null> | null = null;
15
- let healthCheckFails = 0;
7
+ import type { CircuitState } from "./circuit-breaker.js";
8
+ import { createCircuitBreaker } from "./circuit-breaker.js";
9
+
10
+ const DEFAULT_PROXY_HOST = "127.0.0.1";
11
+ const DEFAULT_STARTUP_TIMEOUT_MS = 5_000;
12
+ const DEFAULT_BREAKER_FAILURE_THRESHOLD = 2;
13
+ const DEFAULT_BREAKER_RESET_TIMEOUT_MS = 10_000;
14
+
15
+ type FetchInput = string | URL | Request;
16
+ type ForwardFetch = (input: FetchInput, init?: RequestInit) => Promise<Response>;
17
+
18
+ type ProxyChildProcess = ChildProcess & {
19
+ stdout: NodeJS.ReadableStream | null;
20
+ stderr: NodeJS.ReadableStream | null;
21
+ forwardFetch?: ForwardFetch;
22
+ };
23
+
24
+ export interface BunFetchStatus {
25
+ status: "state" | "fallback";
26
+ mode: "native" | "starting" | "proxy";
27
+ port: number | null;
28
+ bunAvailable: boolean | null;
29
+ childPid: number | null;
30
+ circuitState: CircuitState;
31
+ circuitFailureCount: number;
32
+ reason: string;
33
+ }
34
+
35
+ export interface BunFetchOptions {
36
+ debug?: boolean;
37
+ onProxyStatus?: (status: BunFetchStatus) => void;
38
+ }
39
+
40
+ export interface BunFetchInstance {
41
+ fetch: (input: FetchInput, init?: RequestInit) => Promise<Response>;
42
+ shutdown: () => Promise<void>;
43
+ getStatus: () => BunFetchStatus;
44
+ }
45
+
46
+ interface BunFetchInternal extends BunFetchInstance {
47
+ ensureProxy: (debugOverride?: boolean) => Promise<number | null>;
48
+ fetchWithDebug: (input: FetchInput, init?: RequestInit, debugOverride?: boolean) => Promise<Response>;
49
+ }
16
50
 
17
- const FIXED_PORT = 48372;
18
- const PID_FILE = join(tmpdir(), "opencode-bun-proxy.pid");
19
- const MAX_HEALTH_FAILS = 2;
51
+ interface StartProxyResult {
52
+ child: ProxyChildProcess;
53
+ port: number;
54
+ }
20
55
 
21
- // Kill proxy when parent process exits
22
- let exitHandlerRegistered = false;
23
- function registerExitHandler(): void {
24
- if (exitHandlerRegistered) return;
25
- exitHandlerRegistered = true;
26
- const cleanup = () => { stopBunProxy(); };
27
- process.on("exit", cleanup);
28
- process.on("SIGINT", cleanup);
29
- process.on("SIGTERM", cleanup);
56
+ interface InstanceState {
57
+ activeChild: ProxyChildProcess | null;
58
+ activePort: number | null;
59
+ startingChild: ProxyChildProcess | null;
60
+ startPromise: Promise<number | null> | null;
61
+ bunAvailable: boolean | null;
62
+ pendingFetches: Array<{
63
+ runProxy: (useForwardFetch: boolean) => void;
64
+ runNative: (reason: string) => void;
65
+ }>;
30
66
  }
31
67
 
32
68
  function findProxyScript(): string | null {
33
69
  const dir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
70
+
34
71
  for (const candidate of [
35
72
  join(dir, "bun-proxy.mjs"),
36
73
  join(dir, "..", "dist", "bun-proxy.mjs"),
37
74
  join(dir, "bun-proxy.ts"),
38
75
  ]) {
39
- if (existsSync(candidate)) return candidate;
76
+ if (existsSync(candidate)) {
77
+ return candidate;
78
+ }
40
79
  }
80
+
41
81
  return null;
42
82
  }
43
83
 
44
- let _hasBun: boolean | null = null;
45
- function hasBun(): boolean {
46
- if (_hasBun !== null) return _hasBun;
84
+ function detectBunAvailability(): boolean {
47
85
  try {
48
- execFileSync("which", ["bun"], { stdio: "ignore" });
49
- _hasBun = true;
86
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
87
+ return true;
50
88
  } catch {
51
- _hasBun = false;
89
+ return false;
52
90
  }
53
- return _hasBun;
54
91
  }
55
92
 
56
- function killStaleProxy(): void {
57
- try {
58
- const raw = readFileSync(PID_FILE, "utf-8").trim();
59
- const pid = parseInt(raw, 10);
60
- if (pid > 0) {
61
- try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
62
- }
63
- unlinkSync(PID_FILE);
64
- } catch {
65
- // No PID file or already cleaned
93
+ function toHeaders(headersInit?: RequestInit["headers"]): Headers {
94
+ return new Headers(headersInit ?? undefined);
95
+ }
96
+
97
+ function toRequestUrl(input: FetchInput): string {
98
+ return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
99
+ }
100
+
101
+ function resolveProxySignal(input: FetchInput, init?: RequestInit): AbortSignal | undefined {
102
+ if (init?.signal) {
103
+ return init.signal;
66
104
  }
105
+
106
+ return input instanceof Request ? input.signal : undefined;
67
107
  }
68
108
 
69
- async function isProxyHealthy(port: number): Promise<boolean> {
70
- try {
71
- const resp = await fetch(`http://127.0.0.1:${port}/__health`, {
72
- signal: AbortSignal.timeout(2000),
73
- });
74
- return resp.ok;
75
- } catch {
76
- return false;
109
+ function buildProxyRequestInit(input: FetchInput, init?: RequestInit): RequestInit {
110
+ const targetUrl = toRequestUrl(input);
111
+ const headers = toHeaders(init?.headers);
112
+ const signal = resolveProxySignal(input, init);
113
+ headers.set("x-proxy-url", targetUrl);
114
+
115
+ return {
116
+ ...init,
117
+ headers,
118
+ ...(signal ? { signal } : {}),
119
+ };
120
+ }
121
+
122
+ async function writeDebugArtifacts(url: string, init: RequestInit): Promise<void> {
123
+ if (!init.body || !url.includes("/v1/messages") || url.includes("count_tokens")) {
124
+ return;
77
125
  }
126
+
127
+ const { writeFileSync } = await import("node:fs");
128
+ writeFileSync(
129
+ "/tmp/opencode-last-request.json",
130
+ typeof init.body === "string" ? init.body : JSON.stringify(init.body),
131
+ );
132
+
133
+ const logHeaders: Record<string, string> = {};
134
+ toHeaders(init.headers).forEach((value, key) => {
135
+ logHeaders[key] = key === "authorization" ? "Bearer ***" : value;
136
+ });
137
+
138
+ writeFileSync("/tmp/opencode-last-headers.json", JSON.stringify(logHeaders, null, 2));
78
139
  }
79
140
 
80
- function spawnProxy(): Promise<number | null> {
81
- return new Promise<number | null>((resolve) => {
141
+ export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance {
142
+ const breaker = createCircuitBreaker({
143
+ failureThreshold: DEFAULT_BREAKER_FAILURE_THRESHOLD,
144
+ resetTimeoutMs: DEFAULT_BREAKER_RESET_TIMEOUT_MS,
145
+ });
146
+ const closingChildren = new WeakSet<ProxyChildProcess>();
147
+ const defaultDebug = options.debug ?? false;
148
+ const onProxyStatus = options.onProxyStatus;
149
+ const state: InstanceState = {
150
+ activeChild: null,
151
+ activePort: null,
152
+ startingChild: null,
153
+ startPromise: null,
154
+ bunAvailable: null,
155
+ pendingFetches: [],
156
+ };
157
+
158
+ const getStatus = (reason = "idle", status: BunFetchStatus["status"] = "state"): BunFetchStatus => ({
159
+ status,
160
+ mode: state.activePort !== null ? "proxy" : state.startPromise ? "starting" : "native",
161
+ port: state.activePort,
162
+ bunAvailable: state.bunAvailable,
163
+ childPid: state.activeChild?.pid ?? state.startingChild?.pid ?? null,
164
+ circuitState: breaker.getState(),
165
+ circuitFailureCount: breaker.getFailureCount(),
166
+ reason,
167
+ });
168
+
169
+ const reportStatus = (reason: string): void => {
170
+ onProxyStatus?.(getStatus(reason));
171
+ };
172
+
173
+ const reportFallback = (reason: string, _debugOverride?: boolean): void => {
174
+ onProxyStatus?.(getStatus(reason, "fallback"));
175
+ console.error(
176
+ `[opencode-anthropic-auth] Native fetch fallback engaged (${reason}); Bun proxy fingerprint mimicry disabled for this request`,
177
+ );
178
+ };
179
+
180
+ const resolveDebug = (debugOverride?: boolean): boolean => debugOverride ?? defaultDebug;
181
+
182
+ const clearActiveProxy = (child: ProxyChildProcess | null): void => {
183
+ if (child && state.activeChild === child) {
184
+ state.activeChild = null;
185
+ state.activePort = null;
186
+ }
187
+
188
+ if (child && state.startingChild === child) {
189
+ state.startingChild = null;
190
+ }
191
+ };
192
+
193
+ const flushPendingFetches = (mode: "proxy" | "native", reason = "proxy-unavailable"): void => {
194
+ const pendingFetches = state.pendingFetches.splice(0, state.pendingFetches.length);
195
+ const useForwardFetch = pendingFetches.length <= 2;
196
+ for (const pendingFetch of pendingFetches) {
197
+ if (mode === "proxy") {
198
+ pendingFetch.runProxy(useForwardFetch);
199
+ continue;
200
+ }
201
+
202
+ pendingFetch.runNative(reason);
203
+ }
204
+ };
205
+
206
+ const startProxy = async (debugOverride?: boolean): Promise<number | null> => {
207
+ if (state.activeChild && state.activePort !== null && !state.activeChild.killed) {
208
+ return state.activePort;
209
+ }
210
+
211
+ if (state.startPromise) {
212
+ return state.startPromise;
213
+ }
214
+
215
+ if (!breaker.canExecute()) {
216
+ reportStatus("breaker-open");
217
+ flushPendingFetches("native", "breaker-open");
218
+ return null;
219
+ }
220
+
221
+ if (state.bunAvailable === false) {
222
+ reportStatus("bun-unavailable");
223
+ flushPendingFetches("native", "bun-unavailable");
224
+ return null;
225
+ }
226
+
82
227
  const script = findProxyScript();
83
- if (!script || !hasBun()) {
84
- resolve(null);
85
- return;
228
+ state.bunAvailable = detectBunAvailability();
229
+
230
+ if (!script || !state.bunAvailable) {
231
+ breaker.recordFailure();
232
+ reportStatus(script ? "bun-unavailable" : "proxy-script-missing");
233
+ flushPendingFetches("native", script ? "bun-unavailable" : "proxy-script-missing");
234
+ return null;
86
235
  }
87
236
 
88
- // Kill any stale instance first
89
- killStaleProxy();
237
+ state.startPromise = new Promise<number | null>((resolve) => {
238
+ const debugEnabled = resolveDebug(debugOverride);
90
239
 
91
- try {
92
- const child = spawn("bun", ["run", script, String(FIXED_PORT)], {
93
- stdio: ["ignore", "pipe", "pipe"],
94
- detached: false,
95
- });
96
- proxyProcess = child;
97
- registerExitHandler();
98
-
99
- let done = false;
100
- const finish = (port: number | null) => {
101
- if (done) return;
102
- done = true;
103
- if (port && child.pid) {
104
- try { writeFileSync(PID_FILE, String(child.pid)); } catch { /* ok */ }
240
+ let child: ProxyChildProcess;
241
+
242
+ try {
243
+ child = spawn("bun", ["run", script, "--parent-pid", String(process.pid)], {
244
+ stdio: ["ignore", "pipe", "pipe"],
245
+ env: {
246
+ ...process.env,
247
+ OPENCODE_ANTHROPIC_DEBUG: debugEnabled ? "1" : "0",
248
+ },
249
+ }) as ProxyChildProcess;
250
+ } catch {
251
+ breaker.recordFailure();
252
+ reportStatus("spawn-failed");
253
+ flushPendingFetches("native", "spawn-failed");
254
+ resolve(null);
255
+ return;
256
+ }
257
+
258
+ state.startingChild = child;
259
+ reportStatus("starting");
260
+
261
+ const stdout = child.stdout;
262
+ if (!stdout) {
263
+ clearActiveProxy(child);
264
+ breaker.recordFailure();
265
+ reportStatus("stdout-missing");
266
+ flushPendingFetches("native", "stdout-missing");
267
+ resolve(null);
268
+ return;
269
+ }
270
+
271
+ let settled = false;
272
+ const stdoutLines = readline.createInterface({ input: stdout });
273
+ const startupTimeout = setTimeout(() => {
274
+ finalize(null, "startup-timeout");
275
+ }, DEFAULT_STARTUP_TIMEOUT_MS);
276
+
277
+ startupTimeout.unref?.();
278
+
279
+ const cleanupStartupResources = (): void => {
280
+ clearTimeout(startupTimeout);
281
+ stdoutLines.close();
282
+ };
283
+
284
+ const finalize = (result: StartProxyResult | null, reason: string): void => {
285
+ if (settled) {
286
+ return;
105
287
  }
106
- resolve(port);
288
+
289
+ settled = true;
290
+ cleanupStartupResources();
291
+
292
+ if (result) {
293
+ state.startingChild = null;
294
+ state.activeChild = result.child;
295
+ state.activePort = result.port;
296
+ breaker.recordSuccess();
297
+ reportStatus(reason);
298
+ flushPendingFetches("proxy");
299
+ resolve(result.port);
300
+ return;
301
+ }
302
+
303
+ clearActiveProxy(child);
304
+ breaker.recordFailure();
305
+ reportStatus(reason);
306
+ flushPendingFetches("native", reason);
307
+ resolve(null);
107
308
  };
108
309
 
109
- child.stdout?.on("data", (chunk: Buffer) => {
110
- const m = chunk.toString().match(/BUN_PROXY_PORT=(\d+)/);
111
- if (m) {
112
- proxyPort = parseInt(m[1], 10);
113
- healthCheckFails = 0;
114
- finish(proxyPort);
310
+ stdoutLines.on("line", (line) => {
311
+ const match = line.match(/^BUN_PROXY_PORT=(\d+)$/);
312
+ if (!match) {
313
+ return;
115
314
  }
315
+
316
+ finalize(
317
+ {
318
+ child,
319
+ port: Number.parseInt(match[1], 10),
320
+ },
321
+ "proxy-ready",
322
+ );
116
323
  });
117
324
 
118
- child.on("error", () => {
119
- finish(null);
120
- proxyPort = null;
121
- proxyProcess = null;
122
- starting = null;
325
+ child.once("error", () => {
326
+ finalize(null, "child-error");
123
327
  });
124
328
 
125
- child.on("exit", () => {
126
- proxyPort = null;
127
- proxyProcess = null;
128
- starting = null;
129
- finish(null);
329
+ child.once("exit", () => {
330
+ const shutdownOwned = closingChildren.has(child);
331
+ const isCurrentChild = state.activeChild === child || state.startingChild === child;
332
+
333
+ clearActiveProxy(child);
334
+
335
+ if (!settled) {
336
+ finalize(null, shutdownOwned ? "shutdown-complete" : "child-exit-before-ready");
337
+ return;
338
+ }
339
+
340
+ if (!shutdownOwned && isCurrentChild) {
341
+ breaker.recordFailure();
342
+ reportStatus("child-exited");
343
+ }
130
344
  });
345
+ }).finally(() => {
346
+ state.startPromise = null;
347
+ });
348
+
349
+ return state.startPromise;
350
+ };
131
351
 
132
- // Timeout
133
- setTimeout(() => finish(null), 5000);
134
- } catch {
135
- resolve(null);
352
+ const shutdown = async (): Promise<void> => {
353
+ const children = [state.startingChild, state.activeChild].filter(
354
+ (child): child is ProxyChildProcess => child !== null,
355
+ );
356
+
357
+ state.startPromise = null;
358
+ state.startingChild = null;
359
+ state.activeChild = null;
360
+ state.activePort = null;
361
+
362
+ for (const child of children) {
363
+ closingChildren.add(child);
364
+ if (!child.killed) {
365
+ try {
366
+ child.kill("SIGTERM");
367
+ } catch {
368
+ // Process may have already exited; ignore kill failures
369
+ }
370
+ }
136
371
  }
137
- });
138
- }
139
372
 
140
- export async function ensureBunProxy(): Promise<number | null> {
141
- if (process.env.VITEST || process.env.NODE_ENV === "test") return null;
373
+ breaker.dispose();
374
+ reportStatus("shutdown-requested");
375
+ };
142
376
 
143
- // Fast path: proxy already running and healthy
144
- if (proxyPort && proxyProcess && !proxyProcess.killed) {
145
- return proxyPort;
146
- }
377
+ const fetchThroughProxy = async (
378
+ input: FetchInput,
379
+ init: RequestInit | undefined,
380
+ debugOverride?: boolean,
381
+ ): Promise<Response> => {
382
+ const url = toRequestUrl(input);
383
+ const fetchNative = async (reason: string): Promise<Response> => {
384
+ reportFallback(reason, debugOverride);
147
385
 
148
- // Check if a proxy is already running on the fixed port (from previous session)
149
- if (!proxyPort && await isProxyHealthy(FIXED_PORT)) {
150
- proxyPort = FIXED_PORT;
151
- console.error("[bun-fetch] Reusing existing Bun proxy on port", FIXED_PORT);
152
- return proxyPort;
153
- }
386
+ return globalThis.fetch(input, init);
387
+ };
154
388
 
155
- // Restart if previous instance died
156
- if (proxyPort && (!proxyProcess || proxyProcess.killed)) {
157
- proxyPort = null;
158
- proxyProcess = null;
159
- starting = null;
160
- }
389
+ const fetchFromActiveProxy = async (useForwardFetch: boolean): Promise<Response> => {
390
+ const port = state.activePort;
391
+ if (port === null) {
392
+ return fetchNative("proxy-port-missing");
393
+ }
394
+
395
+ if (resolveDebug(debugOverride)) {
396
+ console.error(`[opencode-anthropic-auth] Routing through Bun proxy at :${port} → ${url}`);
397
+ }
161
398
 
162
- if (starting) return starting;
399
+ if (resolveDebug(debugOverride)) {
400
+ try {
401
+ await writeDebugArtifacts(url, init ?? {});
402
+ if ((init?.body ?? null) !== null && url.includes("/v1/messages") && !url.includes("count_tokens")) {
403
+ console.error("[opencode-anthropic-auth] Dumped request to /tmp/opencode-last-request.json");
404
+ }
405
+ } catch (error) {
406
+ console.error("[opencode-anthropic-auth] Failed to dump request:", error);
407
+ }
408
+ }
163
409
 
164
- starting = spawnProxy();
165
- const port = await starting;
166
- starting = null;
167
- if (port) console.error("[bun-fetch] Bun proxy started on port", port);
168
- else console.error("[bun-fetch] Failed to start Bun proxy, falling back to Node.js fetch");
169
- return port;
170
- }
410
+ const proxyInit = buildProxyRequestInit(input, init);
411
+ const forwardFetch = state.activeChild?.forwardFetch;
171
412
 
172
- export function stopBunProxy(): void {
173
- if (proxyProcess) {
174
- try { proxyProcess.kill(); } catch { /* */ }
175
- proxyProcess = null;
176
- }
177
- proxyPort = null;
178
- starting = null;
179
- killStaleProxy();
180
- }
413
+ const response = await (useForwardFetch && typeof forwardFetch === "function"
414
+ ? forwardFetch(`http://${DEFAULT_PROXY_HOST}:${port}/`, proxyInit)
415
+ : fetch(`http://${DEFAULT_PROXY_HOST}:${port}/`, proxyInit));
181
416
 
182
- /**
183
- * Fetch via Bun proxy for BoringSSL TLS fingerprint.
184
- * Auto-restarts proxy on failure. Falls back to native fetch only if Bun is unavailable.
185
- */
186
- export async function fetchViaBun(
187
- input: string | URL | Request,
188
- init: { headers: Headers; body?: string | null; method?: string; [k: string]: unknown },
189
- ): Promise<Response> {
190
- const port = await ensureBunProxy();
191
- const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
417
+ if (response.status === 502) {
418
+ const errorText = await response.text();
419
+ throw new Error(`Bun proxy upstream error: ${errorText}`);
420
+ }
421
+
422
+ return response;
423
+ };
192
424
 
193
- if (!port) return fetch(input, init as RequestInit);
425
+ if (state.activeChild && state.activePort !== null && !state.activeChild.killed) {
426
+ return fetchFromActiveProxy(true);
427
+ }
194
428
 
195
- const headers = new Headers(init.headers);
196
- headers.set("x-proxy-url", url);
429
+ return new Promise<Response>((resolve, reject) => {
430
+ let settled = false;
431
+ const pendingFetch: InstanceState["pendingFetches"][number] = {
432
+ runProxy: (useForwardFetch: boolean) => {
433
+ if (settled) {
434
+ return;
435
+ }
197
436
 
198
- try {
199
- const resp = await fetch(`http://127.0.0.1:${port}/`, {
200
- method: init.method || "POST",
201
- headers,
202
- body: init.body,
437
+ settled = true;
438
+ void fetchFromActiveProxy(useForwardFetch).then(resolve, reject);
439
+ },
440
+ runNative: (reason: string) => {
441
+ if (settled) {
442
+ return;
443
+ }
444
+
445
+ settled = true;
446
+ void fetchNative(reason).then(resolve, reject);
447
+ },
448
+ };
449
+
450
+ state.pendingFetches.push(pendingFetch);
451
+
452
+ void startProxy(debugOverride).catch(() => {
453
+ state.pendingFetches = state.pendingFetches.filter((candidate) => candidate !== pendingFetch);
454
+ pendingFetch.runNative("proxy-start-error");
455
+ });
203
456
  });
457
+ };
204
458
 
205
- // Proxy returned a 502 — Bun proxy couldn't reach Anthropic
206
- if (resp.status === 502) {
207
- const errText = await resp.text();
208
- throw new Error(`Bun proxy upstream error: ${errText}`);
209
- }
459
+ const instance: BunFetchInternal = {
460
+ fetch(input, init) {
461
+ return fetchThroughProxy(input, init);
462
+ },
463
+ ensureProxy: startProxy,
464
+ fetchWithDebug: fetchThroughProxy,
465
+ shutdown,
466
+ getStatus: () => getStatus(),
467
+ };
468
+
469
+ return instance;
470
+ }
210
471
 
211
- healthCheckFails = 0;
212
- return resp;
213
- } catch (err) {
214
- healthCheckFails++;
215
-
216
- // If proxy seems dead, restart it and retry once
217
- if (healthCheckFails >= MAX_HEALTH_FAILS) {
218
- stopBunProxy();
219
- const newPort = await ensureBunProxy();
220
- if (newPort) {
221
- healthCheckFails = 0;
222
- const retryHeaders = new Headers(init.headers);
223
- retryHeaders.set("x-proxy-url", url);
224
- return fetch(`http://127.0.0.1:${newPort}/`, {
225
- method: init.method || "POST",
226
- headers: retryHeaders,
227
- body: init.body,
228
- });
472
+ const defaultBunFetch = (() => {
473
+ let instance: BunFetchInternal | null = null;
474
+
475
+ return {
476
+ get(): BunFetchInternal {
477
+ if (!instance) {
478
+ instance = createBunFetch() as BunFetchInternal;
229
479
  }
230
- }
231
480
 
232
- // Final fallback: native fetch (will use Node TLS — not ideal but better than failing)
233
- throw err;
234
- }
481
+ return instance;
482
+ },
483
+ async reset(): Promise<void> {
484
+ if (!instance) {
485
+ return;
486
+ }
487
+
488
+ await instance.shutdown();
489
+ instance = null;
490
+ },
491
+ };
492
+ })();
493
+
494
+ export async function ensureBunProxy(debug: boolean): Promise<number | null> {
495
+ return defaultBunFetch.get().ensureProxy(debug);
496
+ }
497
+
498
+ export const stopBunProxy = (): void => {
499
+ void defaultBunFetch.reset();
500
+ };
501
+
502
+ export async function fetchViaBun(
503
+ input: FetchInput,
504
+ init: { headers: Headers; body?: string | null; method?: string; [key: string]: unknown },
505
+ debug: boolean,
506
+ ): Promise<Response> {
507
+ return defaultBunFetch.get().fetchWithDebug(input, init as RequestInit & { headers: Headers }, debug);
235
508
  }