From 97df483e25116f31aaaf9accd90b7f0647e6d517 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 4 Oct 2026 06:57:00 +0000 Subject: [PATCH 1/5] fix: retry overloaded responses and honour Retry-After Only 429 was retried, after a fixed 1s/2s pause. An overloaded (529) response went straight back to the caller, and a 429's Retry-After was ignored: a short one was retried too early, and a usage-limit one that resets in hours was retried twice for nothing. Retry 429 and 529 up to twice, waiting as long as Retry-After asks (exponential backoff when it is absent). A wait longer than 10s is not absorbed inside the request: the response is returned at once, so opencode's own retry loop - which shows the wait and can be cancelled - takes over. The wait ends early when the request is aborted, and the failed response body is released before the retry. 429 keeps its token refresh. --- src/index.test.ts | 139 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 40 +++++++++---- src/retry.ts | 65 ++++++++++++++++++++++ 3 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/retry.ts diff --git a/src/index.test.ts b/src/index.test.ts index 4922d07..c150baa 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,6 +9,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { translateToolArgsJsonString, @@ -31,6 +34,12 @@ import { stripSystemCacheControl, } from "./index.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; +import { + MAX_RETRY_DELAY_MS, + getResponseRetryDelayMs, + parseRetryAfterMs, + waitForRetry, +} from "./retry.js"; // ── Helpers (extracted / reimplemented from index.ts for unit testing) ──────── @@ -1268,3 +1277,133 @@ describe("selectClaudeCredentials", () => { assert.equal(selectClaudeCredentials([null, real]), real); }); }); + +describe("retry policy", () => { + const now = Date.parse("2026-10-04T12:00:00Z"); + + it("reads Retry-After as delay seconds or as an HTTP date", () => { + assert.equal(parseRetryAfterMs("3", now), 3000); + assert.equal(parseRetryAfterMs("Sun, 04 Oct 2026 12:00:05 GMT", now), 5000); + assert.equal(parseRetryAfterMs("Sun, 04 Oct 2026 11:59:00 GMT", now), 0); + assert.equal(parseRetryAfterMs(null, now), null); + assert.equal(parseRetryAfterMs("", now), null); + assert.equal(parseRetryAfterMs("-1", now), null); + assert.equal(parseRetryAfterMs("soon", now), null); + }); + + it("waits as long as Retry-After asks, else backs off", () => { + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": "4" }), 0, now), 4000); + assert.equal(getResponseRetryDelayMs(new Headers(), 0, now), 2000); + assert.equal(getResponseRetryDelayMs(new Headers(), 1, now), 4000); + }); + + it("refuses a wait longer than the bridge absorbs", () => { + const seconds = String(MAX_RETRY_DELAY_MS / 1000 + 1); + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": seconds }), 0, now), null); + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": "18000" }), 0, now), null); + }); + + it("ends a retry wait early when the request is aborted", async () => { + const controller = new AbortController(); + const reason = new Error("aborted by caller"); + const started = Date.now(); + const wait = waitForRetry(5000, controller.signal); + controller.abort(reason); + await assert.rejects(wait, (err) => err === reason); + assert.ok(Date.now() - started < 1000); + await assert.rejects(waitForRetry(10, controller.signal), (err) => err === reason); + }); +}); + +describe("API request retry", () => { + type Reply = { status: number; retryAfter?: string }; + + // Drives the plugin's real fetch wrapper against a stubbed network. Auth is a + // valid stored token, so no refresh (and no real token request) can happen; + // 429 cases only use waits the bridge refuses, which return before refreshing. + // HOME and XDG_DATA_HOME point at a scratch dir so the developer's own Claude + // CLI credentials and opencode log dir are never touched. + async function sendThroughBridge(replies: Reply[], signal?: AbortSignal) { + const originalFetch = globalThis.fetch; + const originalEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + HOME: process.env.HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + }; + const scratchDir = mkdtempSync(join(tmpdir(), "bridge-retry-test-")); + process.env.HOME = scratchDir; + process.env.XDG_DATA_HOME = join(scratchDir, "data"); + const messageCalls: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.includes("/v1/messages")) return new Response("{}", { status: 404 }); + messageCalls.push(url); + const reply = replies[Math.min(messageCalls.length - 1, replies.length - 1)]; + return new Response(JSON.stringify({ type: reply.status === 200 ? "message" : "error" }), { + status: reply.status, + headers: reply.retryAfter === undefined ? {} : { "retry-after": reply.retryAfter }, + }); + }) as typeof fetch; + + try { + const hooks = await pluginModule.server({ client: { auth: { set: async () => {} } } }); + const loaded = await hooks.auth.loader( + async () => ({ + type: "oauth", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 3_600_000, + }), + { models: {} }, + ) as { fetch: (input: string, init?: RequestInit) => Promise }; + const response = await loaded.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ + model: "claude-haiku-4-5", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }), + signal, + }); + return { status: response.status, calls: messageCalls.length }; + } finally { + globalThis.fetch = originalFetch; + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(scratchDir, { recursive: true, force: true }); + } + } + + it("retries an overloaded response and returns the recovered one", async () => { + const result = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 200 }]); + assert.deepEqual(result, { status: 200, calls: 2 }); + }); + + it("stops after two retries and returns the last overloaded response", async () => { + const result = await sendThroughBridge([{ status: 529, retryAfter: "0" }]); + assert.deepEqual(result, { status: 529, calls: 3 }); + }); + + it("returns a long Retry-After at once instead of waiting inside the request", async () => { + const started = Date.now(); + const result = await sendThroughBridge([{ status: 429, retryAfter: "18000" }]); + assert.deepEqual(result, { status: 429, calls: 1 }); + assert.ok(Date.now() - started < 1000); + }); + + it("does not retry other errors", async () => { + const result = await sendThroughBridge([{ status: 500 }]); + assert.deepEqual(result, { status: 500, calls: 1 }); + }); + + it("gives up the retry wait when the request is aborted", async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error("cancelled")), 20); + await assert.rejects( + sendThroughBridge([{ status: 529, retryAfter: "5" }], controller.signal), + /cancelled/, + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index d9fcaac..c040a76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,12 @@ import { startOAuthCallbackServer, } from "./oauth.js"; import { getClaudeTokens, readClaudeCredentials } from "./keychain.js"; +import { + MAX_RESPONSE_RETRIES, + RETRYABLE_STATUSES, + getResponseRetryDelayMs, + waitForRetry, +} from "./retry.js"; import { BETA_FLAGS, CLI_VERSION, @@ -899,7 +905,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { const finalUrl = requestUrl?.toString() ?? (input instanceof Request ? input.url : String(input)); - // ── Request (with 429 auto-refresh retry) ── + // ── Request (with 429/529 retry) ── const outHeaders: Record = {}; headers.forEach((v, k) => { outHeaders[k] = v; }); @@ -912,11 +918,22 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { let response = await doFetch(); - // 429 auto-refresh: rate limits are per-access-token, so refreshing - // the token gives us a fresh rate limit bucket. Try up to 2 retries. - if (response.status === 429) { - for (let retry = 0; retry < 2; retry++) { - console.error(`[opencode-claude-bridge] 429 rate limited (attempt ${retry + 1}/2), refreshing token...`); + // Retry rate-limited (429) and overloaded (529) responses, waiting + // as long as Retry-After asks. A longer wait than the bridge absorbs + // returns the response as-is so opencode's own retry takes over. + for ( + let retry = 0; + retry < MAX_RESPONSE_RETRIES && RETRYABLE_STATUSES.has(response.status); + retry++ + ) { + const delayMs = getResponseRetryDelayMs(response.headers, retry); + if (delayMs === null) break; + const attempt = `attempt ${retry + 1}/${MAX_RESPONSE_RETRIES}`; + + if (response.status === 429) { + // 429 auto-refresh: rate limits are per-access-token, so + // refreshing the token gives us a fresh rate limit bucket. + console.error(`[opencode-claude-bridge] 429 rate limited (${attempt}), refreshing token...`); let freshToken: string | null = null; try { freshToken = await refreshAuth(auth, client); @@ -930,11 +947,14 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { break; } outHeaders["authorization"] = `Bearer ${freshToken}`; - // Back off briefly: 1s first retry, 2s second - await new Promise((r) => setTimeout(r, 1000 * (retry + 1))); - response = await doFetch(); - if (response.status !== 429) break; + } else { + console.error(`[opencode-claude-bridge] ${response.status} overloaded (${attempt}), retrying in ${Math.ceil(delayMs / 1000)}s...`); } + + // Release the failed response's connection before reusing it. + await response.body?.cancel().catch(() => {}); + await waitForRetry(delayMs, init?.signal); + response = await doFetch(); } // ── Map Claude-style tool names and arguments back to OpenCode ── diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..0076ee5 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,65 @@ +/** + * Retry policy for the Messages API wrapper. Pure apart from the timer in + * waitForRetry, so it can be tested without a network. + */ + +/** Statuses the bridge retries itself: rate limited, and overloaded. */ +export const RETRYABLE_STATUSES = new Set([429, 529]); + +/** Retries per request; opencode's own retry loop takes over after these. */ +export const MAX_RESPONSE_RETRIES = 2; + +/** + * Longest wait the bridge sits through before retrying. A longer requested + * wait (a usage limit that resets in hours) is handed straight back to the + * caller: opencode's retry loop shows the wait and can be cancelled, while a + * wait inside fetch looks like a hung request. + */ +export const MAX_RETRY_DELAY_MS = 10_000; + +function getOverloadedBackoffMs(attempt: number): number { + return Math.min(2000 * 2 ** attempt, 8000); +} + +/** Parse a Retry-After value (delay seconds or an HTTP date) into milliseconds. */ +export function parseRetryAfterMs(value: string | null, now = Date.now()): number | null { + if (!value?.trim()) return null; + const seconds = Number(value); + if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : null; + const date = Date.parse(value); + if (Number.isFinite(date)) return Math.max(date - now, 0); + return null; +} + +/** + * Delay before retry `attempt` (0-based) of a retryable response, or null + * when the server asked for a longer wait than the bridge should absorb. + */ +export function getResponseRetryDelayMs( + headers: Headers, + attempt: number, + now = Date.now(), +): number | null { + const delayMs = parseRetryAfterMs(headers.get("retry-after"), now) + ?? getOverloadedBackoffMs(attempt); + return delayMs > MAX_RETRY_DELAY_MS ? null : delayMs; +} + +/** Wait before a retry, ending early (with the abort reason) when the request is aborted. */ +export function waitForRetry(ms: number, signal?: AbortSignal | null): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} From 72e7c0e957ba88ff313f2398c92471d39a9ad6a9 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 4 Oct 2026 06:58:09 +0000 Subject: [PATCH 2/5] fix: retry the token endpoint when it is overloaded The token request retried only 429. An overloaded (529) answer failed the refresh at once, and when 429 retries ran out the caller got a made-up "Rate limited" response instead of what the endpoint actually said. Retry 529 as well, wait as long as the body's retry_after asks, and return the endpoint's real last answer. The request blocks the process while it sleeps, so a requested wait over 10s is not absorbed: the answer is returned straight away. --- src/index.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++-- src/oauth.ts | 20 +++++++----- src/retry.ts | 34 ++++++++++++++++++-- 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index c150baa..6d3c989 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,7 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -20,7 +20,7 @@ import { AGENT_TYPE_OPENCODE_TO_CLAUDE, shouldUseClaudeToolSchemas, } from "./claude-tools.js"; -import { extractOAuthErrorDetail } from "./oauth.js"; +import { extractOAuthErrorDetail, refreshTokens } from "./oauth.js"; import { selectClaudeCredentials } from "./keychain.js"; import { default as pluginModule, @@ -37,6 +37,7 @@ import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; import { MAX_RETRY_DELAY_MS, getResponseRetryDelayMs, + getTokenRetryDelayMs, parseRetryAfterMs, waitForRetry, } from "./retry.js"; @@ -1303,6 +1304,18 @@ describe("retry policy", () => { assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": "18000" }), 0, now), null); }); + it("reads the token endpoint's retry_after from the body", () => { + assert.equal(getTokenRetryDelayMs(529, '{"retry_after":2}', 0), 2000); + assert.equal(getTokenRetryDelayMs(429, '{"error":{"retry_after":"3"}}', 0), 3000); + assert.equal(getTokenRetryDelayMs(529, '{"retry_after":600}', 0), null); + }); + + it("backs off the token endpoint when the body has no retry_after", () => { + assert.equal(getTokenRetryDelayMs(529, "not json", 0), 2000); + assert.equal(getTokenRetryDelayMs(529, "{}", 3), 8000); + assert.equal(getTokenRetryDelayMs(429, "{}", 1, 0.5), 2500); + }); + it("ends a retry wait early when the request is aborted", async () => { const controller = new AbortController(); const reason = new Error("aborted by caller"); @@ -1407,3 +1420,66 @@ describe("API request retry", () => { ); }); }); + +describe("token endpoint retry", () => { + type Reply = { status: number; body: string }; + + // Runs refreshTokens against a stand-in `curl` placed first on PATH, so the + // real retry loop executes without a network. POSIX-only (shebang script). + function refreshWithReplies(replies: Reply[]): { result: unknown; calls: number } { + const dir = mkdtempSync(join(tmpdir(), "bridge-token-test-")); + const originalPath = process.env.PATH; + writeFileSync(join(dir, "replies.json"), JSON.stringify(replies)); + writeFileSync(join(dir, "curl"), `#!${process.execPath} +const fs = require("node:fs"); +const dir = ${JSON.stringify(dir)}; +const countFile = dir + "/count"; +const count = fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, "utf8")) : 0; +fs.writeFileSync(countFile, String(count + 1)); +const replies = JSON.parse(fs.readFileSync(dir + "/replies.json", "utf8")); +const reply = replies[Math.min(count, replies.length - 1)]; +process.stdout.write(reply.body + "\\n__HTTP_STATUS__" + reply.status); +`); + chmodSync(join(dir, "curl"), 0o755); + process.env.PATH = `${dir}:${originalPath}`; + try { + let result: unknown; + try { + result = refreshTokens("test-refresh-token"); + } catch (err) { + result = err instanceof Error ? err.message : err; + } + return { result, calls: Number(readFileSync(join(dir, "count"), "utf8")) }; + } finally { + process.env.PATH = originalPath; + rmSync(dir, { recursive: true, force: true }); + } + } + + const ok = { + status: 200, + body: JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }), + }; + const overloaded = (retryAfter: number) => ({ + status: 529, + body: JSON.stringify({ error: { type: "overloaded_error", message: "Overloaded" }, retry_after: retryAfter }), + }); + + it("retries an overloaded token endpoint", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(0), ok]); + assert.equal(calls, 2); + assert.equal((result as { access: string }).access, "new-access"); + }); + + it("reports the endpoint's real last answer once retries run out", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(0)]); + assert.equal(calls, 3); + assert.match(String(result), /failed \(529\): Overloaded/); + }); + + it("does not sit through a long requested wait", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(600), ok]); + assert.equal(calls, 1); + assert.match(String(result), /failed \(529\)/); + }); +}); diff --git a/src/oauth.ts b/src/oauth.ts index 2acdb23..b76e8fa 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -9,6 +9,7 @@ import { SCOPES, USER_AGENT, } from "./constants.js"; +import { RETRYABLE_STATUSES, getTokenRetryDelayMs } from "./retry.js"; export interface OAuthTokens { access: string; @@ -69,6 +70,7 @@ function curlPost( const payload = JSON.stringify(body); for (let attempt = 0; attempt < retries; attempt++) { + let retryDelayMs: number; try { const result = execFileSync( "curl", @@ -84,23 +86,27 @@ function curlPost( const status = parseInt(parts[parts.length - 1], 10); const responseBody = parts.slice(0, -1).join("\n__HTTP_STATUS__"); - if (status !== 429 || attempt === retries - 1) { + if (!RETRYABLE_STATUSES.has(status) || attempt === retries - 1) { return { status, body: responseBody }; } + // Null when the endpoint asks for a longer wait than this blocking call + // should sit through: hand its real answer back instead. + const delayMs = getTokenRetryDelayMs(status, responseBody, attempt); + if (delayMs === null) return { status, body: responseBody }; + retryDelayMs = delayMs; console.error( - `[opencode-oauth] Token endpoint 429 (attempt ${attempt + 1}/${retries}), retrying...`, + `[opencode-oauth] Token endpoint ${status} (attempt ${attempt + 1}/${retries}), retrying...`, ); } catch (err) { if (attempt === retries - 1) throw err; + retryDelayMs = 1000 * Math.pow(2, attempt) + Math.random() * 1000; } - sleep(1000 * Math.pow(2, attempt) + Math.random() * 1000); + sleep(retryDelayMs); } - return { - status: 429, - body: '{"error":{"type":"rate_limit_error","message":"Rate limited"}}', - }; + // Unreachable: the last attempt always returns or throws. + throw new Error("Token endpoint request was not attempted"); } export function extractOAuthErrorDetail(body: string): string { diff --git a/src/retry.ts b/src/retry.ts index 0076ee5..d8906d7 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,6 +1,7 @@ /** - * Retry policy for the Messages API wrapper. Pure apart from the timer in - * waitForRetry, so it can be tested without a network. + * Retry policy shared by the Messages API wrapper and the OAuth token + * endpoint. Pure apart from the timer in waitForRetry, so it can be tested + * without a network. */ /** Statuses the bridge retries itself: rate limited, and overloaded. */ @@ -45,6 +46,35 @@ export function getResponseRetryDelayMs( return delayMs > MAX_RETRY_DELAY_MS ? null : delayMs; } +/** + * Delay before retry `attempt` (0-based) of the token endpoint, which reports + * its requested wait as `retry_after` seconds in the JSON body. Null when that + * wait is longer than the bridge should absorb — the token request is a + * blocking call, so a long sleep would stall opencode entirely. + */ +export function getTokenRetryDelayMs( + status: number, + body: string, + attempt: number, + random = Math.random(), +): number | null { + let requestedMs: number | null = null; + try { + const parsed = JSON.parse(body) as { + retry_after?: number | string; + error?: { retry_after?: number | string }; + }; + const seconds = Number(parsed.retry_after ?? parsed.error?.retry_after); + if (Number.isFinite(seconds) && seconds >= 0) requestedMs = seconds * 1000; + } catch {} + + const delayMs = requestedMs + ?? (status === 529 + ? getOverloadedBackoffMs(attempt) + : 1000 * 2 ** attempt + random * 1000); + return delayMs > MAX_RETRY_DELAY_MS ? null : delayMs; +} + /** Wait before a retry, ending early (with the abort reason) when the request is aborted. */ export function waitForRetry(ms: number, signal?: AbortSignal | null): Promise { return new Promise((resolve, reject) => { From 8dd7df77065af357ff67200712b7906ea92f8114 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 4 Oct 2026 06:59:03 +0000 Subject: [PATCH 3/5] fix: accept a plain-string system prompt The Messages API takes `system` either as a string or as text blocks, but the request rewrite assumed blocks. A string made it throw before the billing header was added; the error was swallowed and the request went out unrewritten, so the subscription endpoint rejected it. Wrap a string prompt in a single text block before rewriting. --- src/index.test.ts | 67 +++++++++++++++++++++++++++++++++++------------ src/index.ts | 12 ++++++++- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 6d3c989..4efbe9b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -32,6 +32,7 @@ import { shouldInjectClaudeTools, stripAssistantPrefillForClaude, stripSystemCacheControl, + toSystemBlocks, } from "./index.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; import { @@ -1328,7 +1329,7 @@ describe("retry policy", () => { }); }); -describe("API request retry", () => { +describe("bridge fetch wrapper", () => { type Reply = { status: number; retryAfter?: string }; // Drives the plugin's real fetch wrapper against a stubbed network. Auth is a @@ -1336,7 +1337,15 @@ describe("API request retry", () => { // 429 cases only use waits the bridge refuses, which return before refreshing. // HOME and XDG_DATA_HOME point at a scratch dir so the developer's own Claude // CLI credentials and opencode log dir are never touched. - async function sendThroughBridge(replies: Reply[], signal?: AbortSignal) { + async function sendThroughBridge( + replies: Reply[], + signal?: AbortSignal, + requestBody: Record = { + model: "claude-haiku-4-5", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }, + ) { const originalFetch = globalThis.fetch; const originalEnv = { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, @@ -1347,10 +1356,12 @@ describe("API request retry", () => { process.env.HOME = scratchDir; process.env.XDG_DATA_HOME = join(scratchDir, "data"); const messageCalls: string[] = []; - globalThis.fetch = (async (input: string | URL | Request) => { + const sentBodies: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = input instanceof Request ? input.url : String(input); if (!url.includes("/v1/messages")) return new Response("{}", { status: 404 }); messageCalls.push(url); + sentBodies.push(String(init?.body)); const reply = replies[Math.min(messageCalls.length - 1, replies.length - 1)]; return new Response(JSON.stringify({ type: reply.status === 200 ? "message" : "error" }), { status: reply.status, @@ -1371,14 +1382,10 @@ describe("API request retry", () => { ) as { fetch: (input: string, init?: RequestInit) => Promise }; const response = await loaded.fetch("https://api.anthropic.com/v1/messages", { method: "POST", - body: JSON.stringify({ - model: "claude-haiku-4-5", - max_tokens: 16, - messages: [{ role: "user", content: "hi" }], - }), + body: JSON.stringify(requestBody), signal, }); - return { status: response.status, calls: messageCalls.length }; + return { status: response.status, calls: messageCalls.length, sentBodies }; } finally { globalThis.fetch = originalFetch; for (const [name, value] of Object.entries(originalEnv)) { @@ -1390,25 +1397,38 @@ describe("API request retry", () => { } it("retries an overloaded response and returns the recovered one", async () => { - const result = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 200 }]); - assert.deepEqual(result, { status: 200, calls: 2 }); + const { status, calls } = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 200 }]); + assert.deepEqual({ status, calls }, { status: 200, calls: 2 }); }); it("stops after two retries and returns the last overloaded response", async () => { - const result = await sendThroughBridge([{ status: 529, retryAfter: "0" }]); - assert.deepEqual(result, { status: 529, calls: 3 }); + const { status, calls } = await sendThroughBridge([{ status: 529, retryAfter: "0" }]); + assert.deepEqual({ status, calls }, { status: 529, calls: 3 }); }); it("returns a long Retry-After at once instead of waiting inside the request", async () => { const started = Date.now(); - const result = await sendThroughBridge([{ status: 429, retryAfter: "18000" }]); - assert.deepEqual(result, { status: 429, calls: 1 }); + const { status, calls } = await sendThroughBridge([{ status: 429, retryAfter: "18000" }]); + assert.deepEqual({ status, calls }, { status: 429, calls: 1 }); assert.ok(Date.now() - started < 1000); }); it("does not retry other errors", async () => { - const result = await sendThroughBridge([{ status: 500 }]); - assert.deepEqual(result, { status: 500, calls: 1 }); + const { status, calls } = await sendThroughBridge([{ status: 500 }]); + assert.deepEqual({ status, calls }, { status: 500, calls: 1 }); + }); + + it("rewrites a request whose system prompt is a plain string", async () => { + const { status, sentBodies } = await sendThroughBridge([{ status: 200 }], undefined, { + model: "claude-haiku-4-5", + max_tokens: 16, + system: "Be brief.", + messages: [{ role: "user", content: "hi" }], + }); + assert.equal(status, 200); + const system = JSON.parse(sentBodies[0]).system; + assert.ok(Array.isArray(system), "system must be sent as blocks"); + assert.match(system[0].text, /^x-anthropic-billing-header:/); }); it("gives up the retry wait when the request is aborted", async () => { @@ -1483,3 +1503,16 @@ process.stdout.write(reply.body + "\\n__HTTP_STATUS__" + reply.status); assert.match(String(result), /failed \(529\)/); }); }); + +describe("plain-string system prompt", () => { + it("wraps a string in a single text block", () => { + assert.deepEqual(toSystemBlocks("Be brief."), [{ type: "text", text: "Be brief." }]); + }); + + it("passes blocks through and turns an absent prompt into no blocks", () => { + const blocks = [{ type: "text", text: "x" }]; + assert.equal(toSystemBlocks(blocks), blocks); + assert.deepEqual(toSystemBlocks(undefined), []); + assert.deepEqual(toSystemBlocks(""), []); + }); +}); diff --git a/src/index.ts b/src/index.ts index c040a76..26bf11b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -222,6 +222,16 @@ export function rewriteSystemBlocksForModel( }); } +/** + * The Messages API takes `system` as a plain string or as text blocks; the + * request rewrite (billing header, prompt shaping) works on blocks only. + */ +export function toSystemBlocks(system: unknown): unknown { + if (!system) return []; + if (typeof system === "string") return [{ type: "text", text: system }]; + return system; +} + export function stripSystemCacheControl( system: Array<{ type?: string; text?: string; cache_control?: unknown }>, ): Array<{ type?: string; text?: string }> { @@ -769,7 +779,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { parsed.temperature = 1; } - if (!parsed.system) parsed.system = []; + parsed.system = toSystemBlocks(parsed.system); if (Array.isArray(parsed.messages)) { parsed.messages = stripAssistantPrefillForClaude(parsed.messages); } From a105664a77d0f21cd746b2e202521255d609a2b4 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 4 Oct 2026 07:01:01 +0000 Subject: [PATCH 4/5] feat: log failed API responses When a request fails, opencode shows a one-line summary and the error body and request id are gone, which is exactly what is needed to tell a rejected request shape from a rate limit or an outage. Write each failed response that is handed back to opencode (after any retries) to stderr and append it, with its request id, to opencode/log/claude-bridge-api-errors.log under XDG_DATA_HOME (~/.local/share by default), next to opencode's own logs. --- src/index.test.ts | 30 +++++++++++++++++++++++------ src/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 4efbe9b..bc52758 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,7 +9,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -1363,10 +1363,12 @@ describe("bridge fetch wrapper", () => { messageCalls.push(url); sentBodies.push(String(init?.body)); const reply = replies[Math.min(messageCalls.length - 1, replies.length - 1)]; - return new Response(JSON.stringify({ type: reply.status === 200 ? "message" : "error" }), { - status: reply.status, - headers: reply.retryAfter === undefined ? {} : { "retry-after": reply.retryAfter }, - }); + const body = reply.status === 200 + ? { type: "message" } + : { type: "error", error: { type: "api_error", message: `failure ${reply.status}` } }; + const headers: Record = { "request-id": `req_${messageCalls.length}` }; + if (reply.retryAfter !== undefined) headers["retry-after"] = reply.retryAfter; + return new Response(JSON.stringify(body), { status: reply.status, headers }); }) as typeof fetch; try { @@ -1385,7 +1387,9 @@ describe("bridge fetch wrapper", () => { body: JSON.stringify(requestBody), signal, }); - return { status: response.status, calls: messageCalls.length, sentBodies }; + const errorLogPath = join(scratchDir, "data", "opencode", "log", "claude-bridge-api-errors.log"); + const errorLog = existsSync(errorLogPath) ? readFileSync(errorLogPath, "utf8") : ""; + return { status: response.status, calls: messageCalls.length, sentBodies, errorLog }; } finally { globalThis.fetch = originalFetch; for (const [name, value] of Object.entries(originalEnv)) { @@ -1418,6 +1422,20 @@ describe("bridge fetch wrapper", () => { assert.deepEqual({ status, calls }, { status: 500, calls: 1 }); }); + it("records the final failed response in the API error log", async () => { + const { status, errorLog } = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 500 }]); + assert.equal(status, 500); + // Only the response returned to opencode is logged, not the retried ones. + assert.match(errorLog, /status=500 url=https:\/\/api\.anthropic\.com\/v1\/messages\S* request-id=req_2/); + assert.match(errorLog, /response: .*failure 500/); + assert.doesNotMatch(errorLog, /status=529/); + }); + + it("logs nothing for a successful response", async () => { + const { errorLog } = await sendThroughBridge([{ status: 200 }]); + assert.equal(errorLog, ""); + }); + it("rewrites a request whose system prompt is a plain string", async () => { const { status, sentBodies } = await sendThroughBridge([{ status: 200 }], undefined, { model: "claude-haiku-4-5", diff --git a/src/index.ts b/src/index.ts index 26bf11b..bf4c3ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { hostname, userInfo } from "node:os"; -import { join } from "node:path"; +import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir, hostname, userInfo } from "node:os"; +import { dirname, join } from "node:path"; import { createAuthorizationRequest, exchangeCodeForTokens, @@ -326,6 +326,46 @@ const oauthProfileCache = new Map>(); const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH || join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json"); +/** + * Where failed API responses are recorded, next to opencode's own logs. + * opencode only surfaces a one-line summary of a failed request; the full + * error body and request id are what a bug report needs. + */ +function getApiErrorLogPath(): string { + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(dataHome, "opencode", "log", "claude-bridge-api-errors.log"); +} + +/** Record a failed API response on stderr and in the error log; never throws. */ +async function logApiErrorResponse(response: Response, requestUrl: string): Promise { + try { + const requestId = response.headers.get("request-id") || response.headers.get("x-request-id"); + const responseBody = await response.clone().text(); + const statusLine = `[opencode-claude-bridge] API error status=${response.status} url=${requestUrl}` + + (requestId ? ` request-id=${requestId}` : ""); + + console.error(statusLine); + if (responseBody) { + console.error(`[opencode-claude-bridge] API error response: ${responseBody.slice(0, 8000)}`); + } + + try { + const logPath = getApiErrorLogPath(); + mkdirSync(dirname(logPath), { recursive: true }); + const entry = [ + `time=${new Date().toISOString()}`, + statusLine, + responseBody ? `response: ${responseBody}` : "", + ].filter(Boolean).join("\n"); + appendFileSync(logPath, `${entry}\n\n`, "utf8"); + } catch (err) { + console.error(`[opencode-claude-bridge] Failed to write the API error log: ${err}`); + } + } catch (err) { + console.error(`[opencode-claude-bridge] Failed to read the API error response: ${err}`); + } +} + function buildAuthFailureResponse(detail: string): Response { const message = [ "Claude OAuth credentials are no longer valid.", @@ -967,6 +1007,8 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { response = await doFetch(); } + if (!response.ok) await logApiErrorResponse(response, finalUrl); + // ── Map Claude-style tool names and arguments back to OpenCode ── if (!response.body) return response; From b9b6bf72b3ae380de146ec2b7d29068070a8d804 Mon Sep 17 00:00:00 2001 From: Sergei Solo Date: Sun, 4 Oct 2026 07:01:01 +0000 Subject: [PATCH 5/5] docs: describe retries and the API error log --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index f0122e0..e06f8e6 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ SHA256(salt + message[4] + message[7] + message[20] + CLI_VERSION).slice(0, 3) The salt is hardcoded in Claude Code's source. This changes per conversation because the user's message text changes. +### Retries and error log + +Rate-limited (429) and overloaded (529) responses are retried up to twice, waiting as long as `Retry-After` asks. A requested wait longer than 10 seconds is not absorbed inside the request: the response goes straight back to OpenCode, whose own retry loop shows the wait and can be cancelled. The OAuth token endpoint is retried the same way. + +Every failed response handed back to OpenCode is written to stderr and appended, with its request id, to `~/.local/share/opencode/log/claude-bridge-api-errors.log` (under `$XDG_DATA_HOME` when set). + ## Requirements - [OpenCode](https://opencode.ai) v1.2+ @@ -228,6 +234,7 @@ src/ constants.ts — Version strings, URLs, env overrides, header values (117 lines) keychain.ts — macOS Keychain access for OAuth tokens oauth.ts — OAuth PKCE flow with local callback server + retry.ts — Retry policy for API responses and the token endpoint scripts/ validate-opencode-oauth.js — Side-by-side wire comparison tool