fix(opencode): force CLI User-Agent when CLI identity synthesis is enabled (#10222)

This commit is contained in:
adevwithpurpose
2026-08-13 15:53:47 +05:00
committed by GitHub
parent bf71656637
commit ce4abd7ef4
4 changed files with 29 additions and 8 deletions

View File

@@ -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)

View File

@@ -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() ?? ""
);

View File

@@ -47,7 +47,10 @@ function findHeader(headers: Record<string, string>, 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<string, string>,
@@ -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<string, string>,
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;

View File

@@ -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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
forwardOpencodeClientHeaders(headers, {});