@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.
- package/README.md +18 -2
- package/dist/opencode-anthropic-auth-cli.mjs +13 -2
- package/dist/opencode-anthropic-auth-plugin.js +191 -38
- package/package.json +1 -1
- package/src/__tests__/bun-proxy.parallel.test.ts +9 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +1 -5
- package/src/__tests__/helpers/plugin-fetch-harness.ts +1 -0
- package/src/__tests__/helpers/sse.ts +0 -1
- package/src/__tests__/sanitization-regex.test.ts +60 -0
- package/src/accounts.dedup.test.ts +4 -2
- package/src/backoff.test.ts +25 -0
- package/src/backoff.ts +83 -0
- package/src/bun-fetch.test.ts +9 -7
- package/src/bun-fetch.ts +4 -0
- package/src/bun-proxy.test.ts +37 -0
- package/src/bun-proxy.ts +11 -3
- package/src/circuit-breaker.test.ts +2 -2
- package/src/config.test.ts +114 -0
- package/src/config.ts +27 -0
- package/src/env.ts +30 -7
- package/src/index.ts +41 -11
- package/src/request/body.history.test.ts +271 -15
- package/src/request/body.ts +76 -15
- package/src/request/retry.test.ts +27 -0
- package/src/request/retry.ts +44 -9
- package/src/storage.ts +1 -0
- package/src/system-prompt/builder.ts +4 -1
- package/src/system-prompt/sanitize.ts +19 -8
- package/src/types.ts +8 -0
package/src/backoff.test.ts
CHANGED
|
@@ -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.
|
package/src/bun-fetch.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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<
|
|
49
|
-
return import("./bun-fetch.js") as Promise<
|
|
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:
|
|
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
|
|
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
|
|
2
|
-
import { createCircuitBreaker,
|
|
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
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
519
|
-
|
|
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:
|
|
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(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
});
|
|
627
|
+
return fetchWithTransport(
|
|
628
|
+
retryUrl,
|
|
629
|
+
buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
|
|
630
|
+
);
|
|
601
631
|
},
|
|
602
632
|
{ maxRetries: 2 },
|
|
603
633
|
);
|