@vacbo/opencode-anthropic-fix 0.1.3 → 0.1.5

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.
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  calculateBackoffMs,
4
4
  isAccountSpecificError,
5
+ isRetriableNetworkError,
5
6
  parseRateLimitReason,
6
7
  parseRetryAfterHeader,
7
8
  parseRetryAfterMsHeader,
@@ -235,6 +236,30 @@ describe("parseRateLimitReason", () => {
235
236
  });
236
237
  });
237
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // isRetriableNetworkError
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("isRetriableNetworkError", () => {
244
+ it("returns true for retryable connection reset codes", () => {
245
+ const error = Object.assign(new Error("socket died"), {
246
+ code: "ECONNRESET",
247
+ });
248
+
249
+ expect(isRetriableNetworkError(error)).toBe(true);
250
+ });
251
+
252
+ it("returns true for Bun proxy upstream reset messages", () => {
253
+ expect(isRetriableNetworkError(new Error("Bun proxy upstream error: Connection reset by server"))).toBe(true);
254
+ });
255
+
256
+ it("returns false for user abort errors", () => {
257
+ const error = new DOMException("The operation was aborted", "AbortError");
258
+
259
+ expect(isRetriableNetworkError(error)).toBe(false);
260
+ });
261
+ });
262
+
238
263
  // ---------------------------------------------------------------------------
239
264
  // calculateBackoffMs
240
265
  // ---------------------------------------------------------------------------
package/src/backoff.ts CHANGED
@@ -4,6 +4,26 @@ const QUOTA_EXHAUSTED_BACKOFFS = [60_000, 300_000, 1_800_000, 7_200_000];
4
4
  const AUTH_FAILED_BACKOFF = 5_000;
5
5
  const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
6
6
  const MIN_BACKOFF_MS = 2_000;
7
+ const RETRIABLE_NETWORK_ERROR_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "UND_ERR_SOCKET"]);
8
+ const NON_RETRIABLE_ERROR_NAMES = new Set(["AbortError", "TimeoutError", "APIUserAbortError"]);
9
+ const RETRIABLE_NETWORK_ERROR_MESSAGES = [
10
+ "bun proxy upstream error",
11
+ "connection reset by peer",
12
+ "connection reset by server",
13
+ "econnreset",
14
+ "econnrefused",
15
+ "epipe",
16
+ "etimedout",
17
+ "fetch failed",
18
+ "network connection lost",
19
+ "socket hang up",
20
+ "und_err_socket",
21
+ ];
22
+
23
+ interface ErrorWithCode extends Error {
24
+ code?: string;
25
+ cause?: unknown;
26
+ }
7
27
 
8
28
  /**
9
29
  * Parse the Retry-After header from a response.
@@ -132,6 +152,69 @@ function bodyHasAccountError(body: string | object | null | undefined): boolean
132
152
  );
133
153
  }
134
154
 
155
+ function collectErrorChain(error: unknown): ErrorWithCode[] {
156
+ const queue: unknown[] = [error];
157
+ const visited = new Set<unknown>();
158
+ const chain: ErrorWithCode[] = [];
159
+
160
+ while (queue.length > 0) {
161
+ const candidate = queue.shift();
162
+ if (candidate == null || visited.has(candidate)) {
163
+ continue;
164
+ }
165
+
166
+ visited.add(candidate);
167
+
168
+ if (candidate instanceof Error) {
169
+ const typedCandidate = candidate as ErrorWithCode;
170
+ chain.push(typedCandidate);
171
+ if (typedCandidate.cause !== undefined) {
172
+ queue.push(typedCandidate.cause);
173
+ }
174
+ continue;
175
+ }
176
+
177
+ if (typeof candidate === "object" && "cause" in candidate) {
178
+ queue.push((candidate as { cause?: unknown }).cause);
179
+ }
180
+ }
181
+
182
+ return chain;
183
+ }
184
+
185
+ /**
186
+ * Check whether an error represents a transient transport/network failure.
187
+ */
188
+ export function isRetriableNetworkError(error: unknown): boolean {
189
+ if (typeof error === "string") {
190
+ const text = error.toLowerCase();
191
+ return RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => text.includes(signal));
192
+ }
193
+
194
+ const chain = collectErrorChain(error);
195
+ if (chain.length === 0) {
196
+ return false;
197
+ }
198
+
199
+ for (const candidate of chain) {
200
+ if (NON_RETRIABLE_ERROR_NAMES.has(candidate.name)) {
201
+ return false;
202
+ }
203
+
204
+ const code = candidate.code?.toUpperCase();
205
+ if (code && RETRIABLE_NETWORK_ERROR_CODES.has(code)) {
206
+ return true;
207
+ }
208
+
209
+ const message = candidate.message.toLowerCase();
210
+ if (RETRIABLE_NETWORK_ERROR_MESSAGES.some((signal) => message.includes(signal))) {
211
+ return true;
212
+ }
213
+ }
214
+
215
+ return false;
216
+ }
217
+
135
218
  /**
136
219
  * Check whether an HTTP response represents an account-specific error
137
220
  * that would benefit from switching to a different account.
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
2
2
 
3
3
  import { createDeferred, createDeferredQueue } from "./__tests__/helpers/deferred.js";
4
4
  import { createMockBunProxy } from "./__tests__/helpers/mock-bun-proxy.js";
5
+ import type * as FsModule from "node:fs";
6
+ import type * as BunFetchModule from "./bun-fetch.js";
5
7
 
6
8
  let execFileSyncMock: Mock;
7
9
  let spawnMock: Mock;
@@ -20,7 +22,7 @@ vi.mock("node:child_process", () => ({
20
22
  }));
21
23
 
22
24
  vi.mock("node:fs", async () => {
23
- const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
25
+ const actual = await vi.importActual<typeof FsModule>("node:fs");
24
26
 
25
27
  return {
26
28
  ...actual,
@@ -32,7 +34,7 @@ vi.mock("node:fs", async () => {
32
34
  };
33
35
  });
34
36
 
35
- type BunFetchModule = Awaited<typeof import("./bun-fetch.js")> & {
37
+ type BunFetchModuleType = Awaited<typeof BunFetchModule> & {
36
38
  createBunFetch?: (options?: { debug?: boolean; onProxyStatus?: (status: unknown) => void }) => {
37
39
  fetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
38
40
  shutdown: () => Promise<void>;
@@ -41,12 +43,12 @@ type BunFetchModule = Awaited<typeof import("./bun-fetch.js")> & {
41
43
  };
42
44
 
43
45
  async function readBunFetchSource(): Promise<string> {
44
- const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
46
+ const fs = await vi.importActual<typeof FsModule>("node:fs");
45
47
  return fs.readFileSync(new URL("./bun-fetch.ts", import.meta.url), "utf8");
46
48
  }
47
49
 
48
- async function loadBunFetchModule(): Promise<BunFetchModule> {
49
- return import("./bun-fetch.js") as Promise<BunFetchModule>;
50
+ async function loadBunFetchModule(): Promise<BunFetchModuleType> {
51
+ return import("./bun-fetch.js") as Promise<BunFetchModuleType>;
50
52
  }
51
53
 
52
54
  function installMockFetch(implementation?: Parameters<typeof vi.fn>[0]): ReturnType<typeof vi.fn> {
@@ -55,7 +57,7 @@ function installMockFetch(implementation?: Parameters<typeof vi.fn>[0]): ReturnT
55
57
  return fetchMock;
56
58
  }
57
59
 
58
- function getCreateBunFetch(moduleNs: BunFetchModule): NonNullable<BunFetchModule["createBunFetch"]> {
60
+ function getCreateBunFetch(moduleNs: BunFetchModuleType): NonNullable<BunFetchModuleType["createBunFetch"]> {
59
61
  const createBunFetch = moduleNs.createBunFetch;
60
62
 
61
63
  expect(createBunFetch, "T20 must export createBunFetch() for per-instance lifecycle ownership").toBeTypeOf(
@@ -70,7 +72,7 @@ function getCreateBunFetch(moduleNs: BunFetchModule): NonNullable<BunFetchModule
70
72
  }
71
73
 
72
74
  beforeEach(async () => {
73
- const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
75
+ const fs = await vi.importActual<typeof FsModule>("node:fs");
74
76
 
75
77
  vi.resetModules();
76
78
  vi.useRealTimers();
package/src/bun-fetch.ts CHANGED
@@ -172,6 +172,7 @@ export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance
172
172
 
173
173
  const reportFallback = (reason: string, _debugOverride?: boolean): void => {
174
174
  onProxyStatus?.(getStatus(reason, "fallback"));
175
+ // eslint-disable-next-line no-console -- startup diagnostic for Bun unavailability; user-facing fallback notice
175
176
  console.error(
176
177
  `[opencode-anthropic-auth] Native fetch fallback engaged (${reason}); Bun proxy fingerprint mimicry disabled for this request`,
177
178
  );
@@ -393,6 +394,7 @@ export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance
393
394
  }
394
395
 
395
396
  if (resolveDebug(debugOverride)) {
397
+ // eslint-disable-next-line no-console -- debug-gated proxy status log; only emits when OPENCODE_ANTHROPIC_DEBUG=1
396
398
  console.error(`[opencode-anthropic-auth] Routing through Bun proxy at :${port} → ${url}`);
397
399
  }
398
400
 
@@ -400,9 +402,11 @@ export function createBunFetch(options: BunFetchOptions = {}): BunFetchInstance
400
402
  try {
401
403
  await writeDebugArtifacts(url, init ?? {});
402
404
  if ((init?.body ?? null) !== null && url.includes("/v1/messages") && !url.includes("count_tokens")) {
405
+ // eslint-disable-next-line no-console -- debug-gated diagnostic; confirms request artifact dump location
403
406
  console.error("[opencode-anthropic-auth] Dumped request to /tmp/opencode-last-request.json");
404
407
  }
405
408
  } catch (error) {
409
+ // eslint-disable-next-line no-console -- error-path diagnostic surfaced to stderr for operator visibility
406
410
  console.error("[opencode-anthropic-auth] Failed to dump request:", error);
407
411
  }
408
412
  }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createProxyRequestHandler } from "./bun-proxy.js";
4
+
5
+ function makeProxyRequest(headers?: HeadersInit): Request {
6
+ const requestHeaders = new Headers(headers);
7
+ requestHeaders.set("x-proxy-url", "https://api.anthropic.com/v1/messages");
8
+ requestHeaders.set("content-type", "application/json");
9
+
10
+ return new Request("http://127.0.0.1/proxy", {
11
+ method: "POST",
12
+ headers: requestHeaders,
13
+ body: JSON.stringify({ ok: true }),
14
+ });
15
+ }
16
+
17
+ describe("createProxyRequestHandler", () => {
18
+ it("forwards retry requests with keepalive disabled to the upstream fetch", async () => {
19
+ const upstreamFetch = vi.fn(async (_input, init?: RequestInit) => {
20
+ expect(init?.keepalive).toBe(false);
21
+ const forwardedHeaders = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers);
22
+ expect(forwardedHeaders.get("connection")).toBe("close");
23
+ expect(forwardedHeaders.get("x-proxy-disable-keepalive")).toBeNull();
24
+ return new Response("ok", { status: 200 });
25
+ });
26
+ const handler = createProxyRequestHandler({
27
+ fetchImpl: upstreamFetch as typeof fetch,
28
+ allowHosts: ["api.anthropic.com"],
29
+ requestTimeoutMs: 50,
30
+ });
31
+
32
+ const response = await handler(makeProxyRequest({ "x-proxy-disable-keepalive": "true" }));
33
+
34
+ await expect(response.text()).resolves.toBe("ok");
35
+ expect(upstreamFetch).toHaveBeenCalledTimes(1);
36
+ });
37
+ });
package/src/bun-proxy.ts CHANGED
@@ -8,6 +8,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
8
8
  const DEFAULT_PARENT_EXIT_CODE = 1;
9
9
  const DEFAULT_PARENT_POLL_INTERVAL_MS = 5_000;
10
10
  const HEALTH_PATH = "/__health";
11
+ const PROXY_DISABLE_KEEPALIVE_HEADER = "x-proxy-disable-keepalive";
11
12
  const DEBUG_ENABLED = process.env.OPENCODE_ANTHROPIC_DEBUG === "1";
12
13
 
13
14
  interface ProxyRequestHandlerOptions {
@@ -85,11 +86,16 @@ function createDefaultParentWatcherFactory(): ParentWatcherFactory {
85
86
  });
86
87
  }
87
88
 
88
- function sanitizeForwardHeaders(source: Headers): Headers {
89
+ function sanitizeForwardHeaders(source: Headers, forceFreshConnection = false): Headers {
89
90
  const headers = new Headers(source);
90
- ["x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
91
+ [PROXY_DISABLE_KEEPALIVE_HEADER, "x-proxy-url", "host", "connection", "content-length"].forEach((headerName) => {
91
92
  headers.delete(headerName);
92
93
  });
94
+
95
+ if (forceFreshConnection) {
96
+ headers.set("connection", "close");
97
+ }
98
+
93
99
  return headers;
94
100
  }
95
101
 
@@ -161,11 +167,13 @@ async function createUpstreamInit(req: Request, signal: AbortSignal): Promise<Re
161
167
  const method = req.method || "GET";
162
168
  const hasBody = method !== "GET" && method !== "HEAD";
163
169
  const bodyText = hasBody ? await req.text() : "";
170
+ const forceFreshConnection = req.headers.get(PROXY_DISABLE_KEEPALIVE_HEADER) === "true";
164
171
 
165
172
  return {
166
173
  method,
167
- headers: sanitizeForwardHeaders(req.headers),
174
+ headers: sanitizeForwardHeaders(req.headers, forceFreshConnection),
168
175
  signal,
176
+ ...(forceFreshConnection ? { keepalive: false } : {}),
169
177
  ...(hasBody && bodyText.length > 0 ? { body: bodyText } : {}),
170
178
  };
171
179
  }
@@ -1,5 +1,5 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
2
- import { createCircuitBreaker, CircuitBreaker, CircuitState } from "./circuit-breaker.js";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createCircuitBreaker, CircuitState } from "./circuit-breaker.js";
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Circuit Breaker - Core State Tests
@@ -41,6 +41,10 @@ describe("DEFAULT_CONFIG", () => {
41
41
  expect(DEFAULT_CONFIG.signature_emulation.prompt_compaction).toBe("minimal");
42
42
  });
43
43
 
44
+ it("disables sanitize_system_prompt by default", () => {
45
+ expect(DEFAULT_CONFIG.signature_emulation.sanitize_system_prompt).toBe(false);
46
+ });
47
+
44
48
  it("has toast defaults", () => {
45
49
  expect(DEFAULT_CONFIG.toasts.quiet).toBe(false);
46
50
  expect(DEFAULT_CONFIG.toasts.debounce_seconds).toBe(30);
@@ -391,3 +395,113 @@ describe("loadConfig", () => {
391
395
  expect(config.cc_credential_reuse.auto_detect).toBe(false);
392
396
  });
393
397
  });
398
+
399
+ describe("loadConfig - sanitize_system_prompt field", () => {
400
+ beforeEach(() => {
401
+ vi.resetAllMocks();
402
+ delete process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT;
403
+ });
404
+
405
+ afterEach(() => {
406
+ delete process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT;
407
+ });
408
+
409
+ it("honors nested signature_emulation.sanitize_system_prompt = true", () => {
410
+ mockExistsSync.mockReturnValue(true);
411
+ mockReadFileSync.mockReturnValue(
412
+ JSON.stringify({
413
+ signature_emulation: { sanitize_system_prompt: true },
414
+ }),
415
+ );
416
+ const config = loadConfig();
417
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(true);
418
+ });
419
+
420
+ it("honors nested signature_emulation.sanitize_system_prompt = false", () => {
421
+ mockExistsSync.mockReturnValue(true);
422
+ mockReadFileSync.mockReturnValue(
423
+ JSON.stringify({
424
+ signature_emulation: { sanitize_system_prompt: false },
425
+ }),
426
+ );
427
+ const config = loadConfig();
428
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
429
+ });
430
+
431
+ it("honors top-level sanitize_system_prompt alias = true", () => {
432
+ mockExistsSync.mockReturnValue(true);
433
+ mockReadFileSync.mockReturnValue(
434
+ JSON.stringify({
435
+ sanitize_system_prompt: true,
436
+ }),
437
+ );
438
+ const config = loadConfig();
439
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(true);
440
+ });
441
+
442
+ it("honors top-level sanitize_system_prompt alias = false (the user's existing config)", () => {
443
+ mockExistsSync.mockReturnValue(true);
444
+ mockReadFileSync.mockReturnValue(
445
+ JSON.stringify({
446
+ debug: false,
447
+ sanitize_system_prompt: false,
448
+ }),
449
+ );
450
+ const config = loadConfig();
451
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
452
+ expect(config.debug).toBe(false);
453
+ });
454
+
455
+ it("top-level alias takes precedence over nested signature_emulation.sanitize_system_prompt", () => {
456
+ mockExistsSync.mockReturnValue(true);
457
+ mockReadFileSync.mockReturnValue(
458
+ JSON.stringify({
459
+ signature_emulation: { sanitize_system_prompt: true },
460
+ sanitize_system_prompt: false,
461
+ }),
462
+ );
463
+ const config = loadConfig();
464
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
465
+ });
466
+
467
+ it("ignores non-boolean top-level sanitize_system_prompt value", () => {
468
+ mockExistsSync.mockReturnValue(true);
469
+ mockReadFileSync.mockReturnValue(
470
+ JSON.stringify({
471
+ sanitize_system_prompt: "yes",
472
+ }),
473
+ );
474
+ const config = loadConfig();
475
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
476
+ });
477
+
478
+ it("OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT=1 forces it on", () => {
479
+ mockExistsSync.mockReturnValue(true);
480
+ mockReadFileSync.mockReturnValue(JSON.stringify({ sanitize_system_prompt: false }));
481
+ process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT = "1";
482
+ const config = loadConfig();
483
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(true);
484
+ });
485
+
486
+ it("OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT=0 forces it off", () => {
487
+ mockExistsSync.mockReturnValue(true);
488
+ mockReadFileSync.mockReturnValue(JSON.stringify({ sanitize_system_prompt: true }));
489
+ process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT = "0";
490
+ const config = loadConfig();
491
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
492
+ });
493
+
494
+ it("OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT=true forces it on", () => {
495
+ mockExistsSync.mockReturnValue(false);
496
+ process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT = "true";
497
+ const config = loadConfig();
498
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(true);
499
+ });
500
+
501
+ it("OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT=false forces it off", () => {
502
+ mockExistsSync.mockReturnValue(false);
503
+ process.env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT = "false";
504
+ const config = loadConfig();
505
+ expect(config.signature_emulation.sanitize_system_prompt).toBe(false);
506
+ });
507
+ });
package/src/config.ts CHANGED
@@ -60,6 +60,7 @@ export interface AnthropicAuthConfig {
60
60
  enabled: boolean;
61
61
  fetch_claude_code_version_on_startup: boolean;
62
62
  prompt_compaction: "minimal" | "off";
63
+ sanitize_system_prompt: boolean;
63
64
  };
64
65
  override_model_limits: OverrideModelLimitsConfig;
65
66
  custom_betas: string[];
@@ -83,6 +84,7 @@ export const DEFAULT_CONFIG: AnthropicAuthConfig = {
83
84
  enabled: true,
84
85
  fetch_claude_code_version_on_startup: true,
85
86
  prompt_compaction: "minimal",
87
+ sanitize_system_prompt: false,
86
88
  },
87
89
  override_model_limits: {
88
90
  enabled: true,
@@ -193,9 +195,21 @@ function validateConfig(raw: Record<string, unknown>): AnthropicAuthConfig {
193
195
  se.prompt_compaction === "off" || se.prompt_compaction === "minimal"
194
196
  ? se.prompt_compaction
195
197
  : DEFAULT_CONFIG.signature_emulation.prompt_compaction,
198
+ sanitize_system_prompt:
199
+ typeof se.sanitize_system_prompt === "boolean"
200
+ ? se.sanitize_system_prompt
201
+ : DEFAULT_CONFIG.signature_emulation.sanitize_system_prompt,
196
202
  };
197
203
  }
198
204
 
205
+ // Top-level alias: `sanitize_system_prompt` is honored as a convenience so
206
+ // users can flip it on/off without learning the nested signature_emulation
207
+ // schema. The top-level value, when set, takes precedence over the nested
208
+ // one because it's the more specific user intent.
209
+ if (typeof raw.sanitize_system_prompt === "boolean") {
210
+ config.signature_emulation.sanitize_system_prompt = raw.sanitize_system_prompt;
211
+ }
212
+
199
213
  if (raw.override_model_limits && typeof raw.override_model_limits === "object") {
200
214
  const oml = raw.override_model_limits as Record<string, unknown>;
201
215
  config.override_model_limits = {
@@ -368,6 +382,19 @@ function applyEnvOverrides(config: AnthropicAuthConfig): AnthropicAuthConfig {
368
382
  config.signature_emulation.prompt_compaction = "minimal";
369
383
  }
370
384
 
385
+ if (
386
+ env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT === "1" ||
387
+ env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT === "true"
388
+ ) {
389
+ config.signature_emulation.sanitize_system_prompt = true;
390
+ }
391
+ if (
392
+ env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT === "0" ||
393
+ env.OPENCODE_ANTHROPIC_SANITIZE_SYSTEM_PROMPT === "false"
394
+ ) {
395
+ config.signature_emulation.sanitize_system_prompt = false;
396
+ }
397
+
371
398
  if (env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "1" || env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS === "true") {
372
399
  config.override_model_limits.enabled = true;
373
400
  }
package/src/env.ts CHANGED
@@ -99,19 +99,42 @@ export function logTransformedSystemPrompt(body: string | undefined): void {
99
99
  const parsed = JSON.parse(body);
100
100
  if (!Object.hasOwn(parsed, "system")) return;
101
101
  // Avoid circular import: inline the title-check here
102
+ const isTitleGeneratorText = (text: unknown): boolean => {
103
+ if (typeof text !== "string") return false;
104
+ const lowered = text.trim().toLowerCase();
105
+ return lowered.includes("you are a title generator") || lowered.includes("generate a brief title");
106
+ };
107
+
102
108
  const system = parsed.system;
103
109
  if (
104
110
  Array.isArray(system) &&
105
- system.some(
106
- (item: { type?: string; text?: string }) =>
107
- item.type === "text" &&
108
- typeof item.text === "string" &&
109
- (item.text.trim().toLowerCase().includes("you are a title generator") ||
110
- item.text.trim().toLowerCase().includes("generate a brief title")),
111
- )
111
+ system.some((item: { type?: string; text?: string }) => item.type === "text" && isTitleGeneratorText(item.text))
112
112
  ) {
113
113
  return;
114
114
  }
115
+
116
+ // The plugin relocates non-CC system blocks into the first user message
117
+ // wrapped in <system-instructions>. Check there too so title-generator
118
+ // requests are still suppressed from the debug log after the relocation
119
+ // pass runs.
120
+ const messages = parsed.messages;
121
+ if (Array.isArray(messages) && messages.length > 0) {
122
+ const firstMsg = messages[0];
123
+ if (firstMsg && firstMsg.role === "user") {
124
+ const content = firstMsg.content;
125
+ if (typeof content === "string" && isTitleGeneratorText(content)) {
126
+ return;
127
+ }
128
+ if (Array.isArray(content)) {
129
+ for (const block of content) {
130
+ if (block && typeof block === "object" && isTitleGeneratorText((block as { text?: unknown }).text)) {
131
+ return;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+
115
138
  // eslint-disable-next-line no-console -- explicit debug logger gated by OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT
116
139
  console.error(
117
140
  "[opencode-anthropic-auth][system-debug] transformed system:",
package/src/index.ts CHANGED
@@ -71,6 +71,7 @@ export async function AnthropicAuthPlugin({
71
71
  const signatureEmulationEnabled = config.signature_emulation.enabled;
72
72
  const promptCompactionMode =
73
73
  config.signature_emulation.prompt_compaction === "off" ? ("off" as const) : ("minimal" as const);
74
+ const signatureSanitizeSystemPrompt = config.signature_emulation.sanitize_system_prompt === true;
74
75
  const shouldFetchClaudeCodeVersion =
75
76
  signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
76
77
 
@@ -456,6 +457,7 @@ export async function AnthropicAuthPlugin({
456
457
  enabled: signatureEmulationEnabled,
457
458
  claudeCliVersion,
458
459
  promptCompactionMode,
460
+ sanitizeSystemPrompt: signatureSanitizeSystemPrompt,
459
461
  },
460
462
  {
461
463
  persistentUserId: signatureUserId,
@@ -479,6 +481,7 @@ export async function AnthropicAuthPlugin({
479
481
  enabled: signatureEmulationEnabled,
480
482
  claudeCliVersion,
481
483
  promptCompactionMode,
484
+ sanitizeSystemPrompt: signatureSanitizeSystemPrompt,
482
485
  customBetas: config.custom_betas,
483
486
  strategy: config.account_selection_strategy,
484
487
  });
@@ -515,12 +518,40 @@ export async function AnthropicAuthPlugin({
515
518
 
516
519
  let response: Response;
517
520
  const fetchInput = requestInput as string | URL | Request;
518
- try {
519
- 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 {
520
536
  ...requestInit,
521
- body,
522
- headers: requestHeaders,
523
- });
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
+ );
524
555
  } catch (err) {
525
556
  const fetchError = err instanceof Error ? err : new Error(String(err));
526
557
  if (accountManager && account) {
@@ -579,7 +610,7 @@ export async function AnthropicAuthPlugin({
579
610
 
580
611
  let retryCount = 0;
581
612
  const retried = await fetchWithRetry(
582
- async () => {
613
+ async ({ forceFreshConnection }) => {
583
614
  if (retryCount === 0) {
584
615
  retryCount += 1;
585
616
  return response;
@@ -593,11 +624,10 @@ export async function AnthropicAuthPlugin({
593
624
  requestContext.preparedBody === undefined
594
625
  ? undefined
595
626
  : cloneBodyForRetry(requestContext.preparedBody);
596
- return fetchWithTransport(retryUrl, {
597
- ...requestInit,
598
- body: retryBody,
599
- headers: headersForRetry,
600
- });
627
+ return fetchWithTransport(
628
+ retryUrl,
629
+ buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
630
+ );
601
631
  },
602
632
  { maxRetries: 2 },
603
633
  );