From ce4abd7ef4b8ca7177665fe8713e57bd06cf8bdd Mon Sep 17 00:00:00 2001 From: adevwithpurpose Date: Thu, 13 Aug 2026 15:53:47 +0500 Subject: [PATCH] fix(opencode): force CLI User-Agent when CLI identity synthesis is enabled (#10222) --- changelog.d/fixes/opencode-force-cli-ua.md | 1 + open-sse/executors/opencode.ts | 4 +++- open-sse/utils/opencodeHeaders.ts | 19 +++++++++++++++---- ...pencode-cli-headers-synthesis-5997.test.ts | 13 ++++++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/opencode-force-cli-ua.md diff --git a/changelog.d/fixes/opencode-force-cli-ua.md b/changelog.d/fixes/opencode-force-cli-ua.md new file mode 100644 index 0000000000..f8a194a90b --- /dev/null +++ b/changelog.d/fixes/opencode-force-cli-ua.md @@ -0,0 +1 @@ +- **fix(providers):** when `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, a non-CLI client User-Agent (e.g. `curl/8.5.0`, SDKs) on opencode-go/opencode-zen/opencode-free requests is now REPLACED with the synthesized `opencode-cli/1.0.0` instead of being honored — opencode.ai's free tier (`/zen/v1`) returns `FreeUsageLimitError` 429 for generic client UAs egressing from datacenter IPs, which made the #5997 CLI-identity synthesis ineffective for non-CLI clients. Client UAs already matching `opencode-cli/…` are preserved (the real CLI's versioned identity stays intact); all other client-supplied `x-opencode-*` headers keep client-wins. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (7, incl. non-CLI UA replaced + CLI UA preserved). (#5997 follow-up) diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 75f7e372f5..dddcbe4900 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -367,7 +367,9 @@ export class OpencodeExecutor extends BaseExecutor { // value risks upstream rejection (#5720 regressed with "opencode/local"), and this // is deployment-specific. So it stays OFF by default and the VPS operator enables it // with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied - // headers always take precedence. + // headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is + // replaced with the synthesized CLI UA because opencode.ai's free tier rejects + // generic client UAs from datacenter IPs (FreeUsageLimitError 429). const synthesizeCli = /^(1|true|yes|on)$/i.test( process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" ); diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 8569c8512c..81d9a9f79f 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -47,7 +47,10 @@ function findHeader(headers: Record, name: string): string | und * the OpenCode CLI identity headers that Cloudflare requires on VPS egress * (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session * UUIDs, but ONLY for keys the client did not already supply. Client values always - * win; these defaults only fill gaps. (#5997) + * win; these defaults only fill gaps. User-Agent is the one exception: a client UA + * that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the + * synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs + * from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229) */ export function forwardOpencodeClientHeaders( headers: Record, @@ -100,14 +103,22 @@ export function forwardOpencodeClientHeaders( } /** - * Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress, but only for - * keys the client did not already supply (client values always win). (#5997) + * Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For + * x-opencode-* headers, client values always win (defaults only fill gaps). The + * User-Agent is the exception: a non-CLI client UA (curl, python, SDKs) is replaced + * with the synthesized CLI UA, because opencode.ai's free tier flags generic client + * UAs from datacenter IPs (FreeUsageLimitError 429). A client UA that already looks + * like the OpenCode CLI (opencode-cli/...) is preserved so the real CLI's versioned + * identity stays intact. (#5997, follow-up) */ function applyCliDefaults( headers: Record, cliDefaults: { userAgent: string; client: string; project: string } ): void { - if (!headers["User-Agent"] && !headers["user-agent"]) { + const existingUa = headers["User-Agent"] || headers["user-agent"]; + const clientUaIsCliLike = + typeof existingUa === "string" && /^opencode-cli\//i.test(existingUa.trim()); + if (!clientUaIsCliLike) { setUserAgentHeader(headers, cliDefaults.userAgent); } headers["x-opencode-client"] ||= cliDefaults.client; diff --git a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts index ee45237fe1..a652f8dbae 100644 --- a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts +++ b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts @@ -53,10 +53,10 @@ test("forwardOpencodeClientHeaders: cliDefaults synthesize all CLI identity head assert.notEqual(headers["x-opencode-request"], headers["x-opencode-session"]); }); -test("forwardOpencodeClientHeaders: client-supplied CLI headers take precedence over defaults [#5997]", () => { +test("forwardOpencodeClientHeaders: non-CLI client UA is REPLACED with the CLI UA; other headers keep client-wins [#5997 follow-up]", () => { const headers: Record = {}; const clientHeaders = { - "User-Agent": "my-tool/9.9", + "User-Agent": "curl/8.5.0", "x-opencode-client": "vscode", "x-opencode-project": "acme", "x-opencode-request": "req-from-client", @@ -64,13 +64,20 @@ test("forwardOpencodeClientHeaders: client-supplied CLI headers take precedence }; forwardOpencodeClientHeaders(headers, clientHeaders, { cliDefaults: CLI_DEFAULTS }); - assert.equal(headers["User-Agent"], "my-tool/9.9"); + assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); assert.equal(headers["x-opencode-client"], "vscode"); assert.equal(headers["x-opencode-project"], "acme"); assert.equal(headers["x-opencode-request"], "req-from-client"); assert.equal(headers["x-opencode-session"], "sess-from-client"); }); +test("forwardOpencodeClientHeaders: an existing opencode-cli UA is preserved (real CLI version intact)", () => { + const headers: Record = {}; + const clientHeaders = { "User-Agent": "opencode-cli/2.5.0" }; + forwardOpencodeClientHeaders(headers, clientHeaders, { cliDefaults: CLI_DEFAULTS }); + assert.equal(headers["User-Agent"], "opencode-cli/2.5.0"); +}); + test("forwardOpencodeClientHeaders: without cliDefaults, no synthesis (DefaultExecutor path unchanged)", () => { const headers: Record = {}; forwardOpencodeClientHeaders(headers, {});