From cade5b13238d2dd2db4a7814a08460c203c3dacc Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:08:00 +0700 Subject: [PATCH 1/2] fix(opencode): make the free tier work standalone (tool fingerprint + session value) OpenCode Free (`opencode`) and the free models of OpenCode Zen (`opencode-zen`) were answered `403 FreeTierError` whenever OmniRoute ran standalone, because two legs of the upstream client contract were missing: - the `tools` list is a fingerprint, not a non-empty check: the lowercase quartet `bash`/`glob`/`grep`/`read` must be declared, while a lone `_noop` placeholder, a single client tool or the capitalised `Bash` are refused; - the `x-opencode-session` value is decoded through the client's `Identifier.descending()` layout, so a shape-valid id with a foreign encoding is refused as well. The id is now re-minted inside the counter window and stays a pure function of the conversation seed, so prompt-cache affinity is preserved. The quartet is additive - the caller's own tool spellings are never renamed - the refusal-retry merge restores it too, and `opencode-go` plus paid models are untouched (the `go` surface refuses requests carrying tools). Validated live: both providers answer 200 through the patched executor from a standalone process (no CLI, no credentials), and the request it puts on the wire matches the one the real client sends. Refs #16571, #16328 --- .../0000-opencode-free-tier-standalone.md | 1 + docs/reference/FREE_TIERS.md | 22 +- open-sse/executors/opencode.ts | 1 + .../executors/opencodeFreeTierContract.ts | 49 ++- open-sse/executors/opencodeFreeTierRetry.ts | 5 + open-sse/utils/opencodeHeaders.ts | 79 ++++- ...pencode-free-tier-refusal-rotation.test.ts | 12 +- ...pencode-free-tier-request-contract.test.ts | 133 +++++--- .../opencode-quota-retry-own-tools.test.ts | 2 +- .../unit/opencode-request-shape-retry.test.ts | 2 +- ...pencode-standalone-free-tier-16571.test.ts | 321 ++++++++++++++++++ 11 files changed, 563 insertions(+), 64 deletions(-) create mode 100644 changelog.d/fixes/0000-opencode-free-tier-standalone.md create mode 100644 tests/unit/opencode-standalone-free-tier-16571.test.ts diff --git a/changelog.d/fixes/0000-opencode-free-tier-standalone.md b/changelog.d/fixes/0000-opencode-free-tier-standalone.md new file mode 100644 index 000000000000..439b8ff596b4 --- /dev/null +++ b/changelog.d/fixes/0000-opencode-free-tier-standalone.md @@ -0,0 +1 @@ +- **fix(opencode):** OpenCode Free (`opencode`) and OpenCode Zen free models now answer when OmniRoute is used standalone, without the OpenCode CLI in the loop. Two legs of the upstream client contract were missing: the `tools` list is a fingerprint rather than a non-empty check — the lowercase quartet `bash`/`glob`/`grep`/`read` must be declared, while a lone `_noop` placeholder, a single client tool or the capitalised `Bash` spelling are all answered `403 FreeTierError` — and the `x-opencode-session` value is decoded, so an id that only matched the shape (the previous `12 arbitrary hex` render) is now re-minted in the client's own descending layout, staying stable per conversation so prompt caching still hits. The caller's own tools are kept as they are; the quartet is added beside them, and `opencode-go` plus paid models are untouched. diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index c3f072a64783..b848ecefc733 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -292,8 +292,26 @@ sentence _"OpenCode's free tier can only be used from within OpenCode"_. This is request-scoped refusal (same verdict on every account for the same request shape), not a model ban or connection cooldown — OmniRoute classifies it as `project_route_error`, skips model lockout / cooldown, and (on the synthetic `noauth` path) pauses auto-combo re-selection -for a short TTL. Ship requests that carry a non-empty tool list, `stream: true`, and the -OpenCode session/UA headers (`opencodeFreeTierContract.ts`) or expect the 403. +for a short TTL. + +The gate has four legs, and the last two are narrower than they look: + +1. `stream: true`. +2. A `tools` list that declares the lowercase quartet `bash`, `glob`, `grep`, `read`. It is a + fingerprint, not a non-empty check: measured live on 2026-10-02/03 over `/chat/completions` + and `/responses` on 9 gated free models, an empty list, a lone `_noop` placeholder, a + single client tool and the capitalised `Bash` spelling are all answered 403, while the + quartet is answered 200. Order is irrelevant and extra tools beside the quartet are + accepted, so a caller's own entries are never renamed away. +3. An `x-opencode-session` whose 12 hex digits decode through the client's + `Identifier.descending()` layout into the counter window `0xFC0-0xFFF` (counter 0-63). A + shape-valid id from another scheme — a raw hash, an ascending id, a counter out of window — + is refused like a missing one (measured 2026-10-13/14). +4. A `User-Agent` containing `opencode/= 1.17>`; a lower version answers 426. + +OmniRoute synthesizes all four for a caller that arrives without them +(`opencodeFreeTierContract.ts` for the body, `opencodeHeaders.ts` for the session), which is +what makes the provider usable from any client rather than only from the OpenCode CLI. ## What changed since the shipped catalog (`freeNote`) diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index bec67e51a89c..0d2f7edb4c59 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1361,6 +1361,7 @@ export class OpencodeExecutor extends BaseExecutor { cliDefaults, keepAgentUserAgent: this._surface() === "go" && !gatedScope, // #15311 sessionBody: projectOpencodeSessionBody(body), + reencodeSession: gatedScope, // #16571: the free tier decodes the session's value }); } diff --git a/open-sse/executors/opencodeFreeTierContract.ts b/open-sse/executors/opencodeFreeTierContract.ts index 328333a59b74..d9b89d50d7c4 100644 --- a/open-sse/executors/opencodeFreeTierContract.ts +++ b/open-sse/executors/opencodeFreeTierContract.ts @@ -196,11 +196,32 @@ const PLACEHOLDER_TOOL_DESCRIPTION = "Do not call this tool. It exists only for API compatibility and must never be invoked."; const PLACEHOLDER_TOOL_PARAMETERS = { type: "object", properties: {} } as const; +/** + * The lowercase tool quartet the zen surface fingerprints. + * + * The upstream does not merely require `tools` to be non-empty: it inspects the declared + * names and casing. Measured live on 2026-10-02/03 across `/chat/completions` and + * `/responses` on 9 gated free models (121 records, independently reproduced in #15322): + * an empty list, a lone `_noop`, a single client tool, and the capitalised `Bash` spelling + * are all answered 403 FreeTierError, while `bash + read` — the smallest passing pair — and + * this quartet are answered 200. Order is irrelevant and extra tools alongside the quartet + * are accepted, which is why the caller's own entries are never renamed away: the four + * names are only ADDED when missing, so a client that spells one differently (`Bash` from + * Claude Code) keeps its own entry and gains the canonical one next to it. + */ +export const OPENCODE_FREE_TIER_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"] as const; + +/** The quartet entries a body is still missing, caller entries untouched. */ +function missingFingerprintToolNames(existing: ReadonlySet): string[] { + return OPENCODE_FREE_TIER_FINGERPRINT_TOOLS.filter((name) => !existing.has(name)); +} + /** * Bring a free-tier request up to the upstream contract, without overriding anything the - * caller already decided: client tools are kept as they are, and the placeholder tool is - * only added when the caller sent none or when client-supplied tools do not yet carry the - * required placeholder tool. Idempotent. + * caller already decided: client tools are kept as they are, and the entries the upstream + * fingerprints are only added when the caller's own list does not spell them exactly — the + * lowercase quartet first (see `OPENCODE_FREE_TIER_FINGERPRINT_TOOLS`), then the resolved + * placeholder names. Idempotent. * * The placeholder differs per surface: Chat Completions takes the nested function shape, * the Responses surface takes the flat one. Neither carries a `tool_choice` — the upstream @@ -223,7 +244,16 @@ export function applyFreeTierRequestContract( const existingNames = new Set(clientToolNamesOf(next)); const baseNames = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME]; - const namesToAdd = baseNames.filter((name) => !existingNames.has(name)); + // The quartet goes in first: it is what the upstream fingerprints, so it is added whenever + // the caller's own list does not spell the four names exactly (a `Bash` from Claude Code + // counts as missing `bash`). The placeholder names stay additive afterwards, deduplicated + // against the quartet and against each other. + const namesToAdd: string[] = []; + for (const name of [...missingFingerprintToolNames(existingNames), ...baseNames]) { + if (existingNames.has(name)) continue; + existingNames.add(name); + namesToAdd.push(name); + } if (namesToAdd.length === 0) return next as T; @@ -292,7 +322,16 @@ export function mergeClientToolsWithObserved( const own = session ? getObservedToolNames(provider, model, session) : null; const observed = own ?? getObservedToolNames(provider, model) ?? configured; const have = new Set(clientToolNamesOf(body)); - const missing = observed.filter((name) => !have.has(name)); + // The quartet is mandatory on this surface too, and the first dispatch carried it whether + // or not the client did — so a refusal on the client's own names is retried with the quartet + // restored first, then the observed names, deduplicated in order. + const missing = missingFingerprintToolNames(have); + const declared = new Set([...have, ...missing]); + for (const name of observed) { + if (declared.has(name)) continue; + declared.add(name); + missing.push(name); + } if (missing.length === 0) return body; const next: Record = { ...(record as Record) }; if (requestFormat === "openai-responses") { diff --git a/open-sse/executors/opencodeFreeTierRetry.ts b/open-sse/executors/opencodeFreeTierRetry.ts index 7c6cecea05d6..0a8be158d3d7 100644 --- a/open-sse/executors/opencodeFreeTierRetry.ts +++ b/open-sse/executors/opencodeFreeTierRetry.ts @@ -3,6 +3,7 @@ import { configuredPlaceholderToolNames, isGatedFreeTierRequest, mergeClientToolsWithObserved, + OPENCODE_FREE_TIER_FINGERPRINT_TOOLS, type OpencodeSurface, } from "./opencodeFreeTierContract.ts"; import { resolvePlaceholderNames } from "./opencodeToolObservation.ts"; @@ -98,6 +99,10 @@ function addsNothingBeyondFirstDispatch( ): boolean { const sent = new Set([ ...ctx.clientToolNames, + // The first dispatch always carried the quartet: the contract adds it to every gated + // body, so a retry that would only restore it re-sends a shape the upstream has just + // answered — nothing new to learn, one wasted round trip. + ...OPENCODE_FREE_TIER_FINGERPRINT_TOOLS, ...resolvePlaceholderNames(ctx.provider, model, ctx.clientSession, configured), ]); return toolNamesOf(merged).every((name) => sent.has(name)); diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 6939db14c882..ae6eb9a97b25 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -19,6 +19,23 @@ export const OPENCODE_SESSION_PATTERN = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; /** Same shape for the request id, which the upstream accepts but does not validate. */ export const OPENCODE_REQUEST_PATTERN = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; +/** + * Same shape, and the VALUE the zen free tier decodes back. + * + * The 12 hex digits are read through the client's `Identifier.descending()` layout, where the + * low 12 bits carry `0xFFF - counter` (counter is the within-millisecond sequence). Only a + * low-12 landing in `0xFC0-0xFFF` — counter 0-63 — passes; a shape-valid id from another + * scheme (raw hash, ascending id, counter out of window) is answered `403 FreeTierError` like + * a missing one (measured 2026-10-13/14, #16328). `OPENCODE_SESSION_PATTERN` alone cannot + * tell the two apart, so the gated path checks this one. + */ +export const OPENCODE_SESSION_VALUE_PATTERN = /^ses_[0-9a-f]{9}f[c-f][0-9a-f][0-9A-Za-z]{14}$/; + +/** Whether a session id carries the encoding the zen free tier decodes (not just its shape). */ +export function isCanonicalOpencodeSessionValue(sessionId: string | null | undefined): boolean { + return OPENCODE_SESSION_VALUE_PATTERN.test(String(sessionId ?? "").trim()); +} + const MINIMUM_USER_AGENT_MINOR = 17; const USER_AGENT_VERSION_RE = /opencode\/(?:[a-z]+\/)?v?(\d+)\.(\d+)/i; @@ -99,19 +116,51 @@ function base62From(bytes: Buffer, length: number): string { return Array.from(bytes.subarray(0, length), (byte) => BASE62[byte % 62]).join(""); } +/** Counter OpenCode's `Identifier.descending()` inverts; 0-63 is the window the gate accepts. */ +const OPENCODE_SESSION_COUNTER = 1n; +/** The inverted 48-bit field the 12 hex digits render. */ +const OPENCODE_SESSION_LOW_48_MASK = 0xffffffffffffn; +/** 36 bits of timestamp: 48 bits minus the 12 the counter owns. */ +const OPENCODE_SESSION_TS36_MASK = 0xfffffffffn; + +/** + * Render the 12 hex digits the way the client's `Identifier.descending()` does: + * `~(timestamp36 << 12 | counter)` over 48 bits, most-significant nibble first. + * + * The counter is pinned to 1 — the deterministic stand-in for the client's per-millisecond + * sequence — so the whole id stays a pure function of the seed and one conversation keeps + * one upstream session (prompt-cache affinity). The timestamp half is taken from the top 36 + * bits of the seed's hash: the upstream does not check that the timestamp is a real clock + * reading, only the counter window the inverted low 12 bits land in. + */ +function descendingTimeHex(bytes: Buffer): string { + const ts36 = + ((BigInt(bytes[0]) << 28n) | + (BigInt(bytes[1]) << 20n) | + (BigInt(bytes[2]) << 12n) | + (BigInt(bytes[3]) << 4n) | + (BigInt(bytes[4]) >> 4n)) & + OPENCODE_SESSION_TS36_MASK; + const value = ~((ts36 << 12n) | OPENCODE_SESSION_COUNTER) & OPENCODE_SESSION_LOW_48_MASK; + return value.toString(16).padStart(12, "0"); +} + /** * Render an id in the canonical OpenCode shape (`` + 12 hex + 14 base62). * - * The upstream checks the shape and not the value: 12 arbitrary hex digits pass, so - * there is no need to reproduce the client's own id algorithm (timestamp plus counter). - * With a seed the result is deterministic, which is what keeps a conversation on one - * upstream session — and therefore keeps prompt caching warm — across requests. + * For `ses_` the hex half must also carry the client's `Identifier.descending()` encoding, + * because the zen free tier decodes it and refuses a value outside the counter window + * (`OPENCODE_SESSION_VALUE_PATTERN`). With a seed the result is deterministic, which is what + * keeps a conversation on one upstream session — and therefore keeps prompt caching warm — + * across requests. `msg_` ids are not value-checked upstream, so they keep the plain render. */ function canonicalId(prefix: "ses_" | "msg_", seed?: string): string { const bytes = seed ? createHash("sha256").update(`opencode\u0000${prefix}\u0000${seed}`).digest() : randomBytes(32); - return `${prefix}${bytes.subarray(0, 6).toString("hex")}${base62From(bytes.subarray(6), 14)}`; + const timePart = + prefix === "ses_" ? descendingTimeHex(bytes) : bytes.subarray(0, 6).toString("hex"); + return `${prefix}${timePart}${base62From(bytes.subarray(6), 14)}`; } /** @@ -175,6 +224,11 @@ function findHeader(headers: Record, name: string): string | und * conversation-stable session fingerprint (model, system, messages or input, tools). * When provided, x-opencode-session is a deterministic hash instead of a random * UUID, so upstream prompt caching hits across requests in the same conversation. + * @param options.reencodeSession - Zen free tier only (#16571/#16328): the upstream decodes + * `x-opencode-session` and refuses a value outside the client's counter window, so a + * caller value that only matches the SHAPE is re-minted from its own value (same + * conversation, same session, canonical encoding). Gated surfaces only: `opencode-go` and + * paid models keep the caller's session untouched (#15311). */ export function forwardOpencodeClientHeaders( headers: Record, @@ -185,6 +239,7 @@ export function forwardOpencodeClientHeaders( keepAgentUserAgent?: boolean; sessionBody?: OpencodeSessionBody; forwardUserAgent?: boolean; + reencodeSession?: boolean; } ): void { // 1. Forward User-Agent (opt-out for generic providers, #15632) @@ -221,7 +276,8 @@ export function forwardOpencodeClientHeaders( headers, options.cliDefaults, options.sessionBody, - options.keepAgentUserAgent === true + options.keepAgentUserAgent === true, + options.reencodeSession === true ); } } @@ -266,7 +322,8 @@ function applyCliDefaults( headers: Record, cliDefaults: { userAgent: string; client: string; project: string }, sessionBody?: OpencodeSessionBody, - keepAgentUserAgent = false + keepAgentUserAgent = false, + reencodeSession = false ): void { if (!keepsClientUserAgent(headers["User-Agent"] || headers["user-agent"], keepAgentUserAgent)) { setUserAgentHeader(headers, cliDefaults.userAgent); @@ -281,9 +338,15 @@ function applyCliDefaults( clientRequestId && OPENCODE_REQUEST_PATTERN.test(clientRequestId) ? clientRequestId : canonicalId("msg_", clientRequestId || undefined); + // On the gated path the shape is not enough: a client session whose encoded value sits + // outside the counter window is refused by the upstream exactly like a missing one, so it + // is re-minted from the client's own value (one conversation, one stable session). const clientSessionId = headers["x-opencode-session"]?.trim(); + const keepsClientSession = reencodeSession + ? isCanonicalOpencodeSessionValue(clientSessionId) + : Boolean(clientSessionId && OPENCODE_SESSION_PATTERN.test(clientSessionId)); headers["x-opencode-session"] = clientSessionId - ? OPENCODE_SESSION_PATTERN.test(clientSessionId) + ? keepsClientSession ? clientSessionId : canonicalId("ses_", clientSessionId) : canonicalId("ses_", generateSessionId(sessionBody ?? null) ?? undefined); diff --git a/tests/unit/opencode-free-tier-refusal-rotation.test.ts b/tests/unit/opencode-free-tier-refusal-rotation.test.ts index 3be93880abb8..9fd81f8905e8 100644 --- a/tests/unit/opencode-free-tier-refusal-rotation.test.ts +++ b/tests/unit/opencode-free-tier-refusal-rotation.test.ts @@ -303,12 +303,12 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => { assert.strictEqual(seenTools.length, 2, "exactly one retry dispatch"); assert.deepEqual( seenTools[0], - ["glob", "read", "bash"], - "first dispatch keeps client tools first, then the configured placeholders (#14156)" + ["glob", "read", "bash", "grep"], + "first dispatch keeps client tools first, then the required quartet and the placeholders (#14156)" ); assert.deepEqual( seenTools[1], - ["glob", "read", "edit", "write", "bash"], + ["glob", "read", "bash", "grep", "edit", "write"], "retry appends observed names after client tools" ); await result.response.body?.cancel(); @@ -345,7 +345,11 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => { }); assert.strictEqual(result.response.status, 403); - assert.deepEqual(seenTools, [["glob", "read", "edit", "write"]], "one dispatch, no duplicate"); + assert.deepEqual( + seenTools, + [["glob", "read", "bash", "grep", "edit", "write"]], + "one dispatch, no duplicate" + ); }); // Retry refusal: the ORIGINAL 403 is propagated and the store is untouched. diff --git a/tests/unit/opencode-free-tier-request-contract.test.ts b/tests/unit/opencode-free-tier-request-contract.test.ts index 020adbdc6390..378cefd28fa5 100644 --- a/tests/unit/opencode-free-tier-request-contract.test.ts +++ b/tests/unit/opencode-free-tier-request-contract.test.ts @@ -82,13 +82,17 @@ test("the surface is told apart by base url, so the `oc` alias needs no special assert.equal(surfaceFromBaseUrl(undefined), "other"); }); -test("chat completions: the contract adds streaming and a placeholder tool, and no tool_choice", () => { +test("chat completions: the contract adds streaming and the required tool list, and no tool_choice", () => { const body = applyFreeTierRequestContract(CHAT_BODY(), "openai") as Record; assert.equal(body.stream, true); const tools = body.tools as Array<{ type: string; function: { name: string } }>; - assert.equal(tools.length, 1); + assert.equal(tools.length, 5); assert.equal(tools[0].type, "function"); - assert.equal(tools[0].function.name, "_noop"); + // The quartet the upstream fingerprints comes first, the built-in placeholder after it. + assert.deepEqual( + tools.map((tool) => tool.function.name), + ["bash", "glob", "grep", "read", "_noop"] + ); // The upstream answers 400 `only "auto" is supported for tool_choice` (measured // 2026-09-18 on the Chat Completions surface), so none is imposed here either. assert.equal("tool_choice" in body, false); @@ -96,30 +100,31 @@ test("chat completions: the contract adds streaming and a placeholder tool, and test("the placeholder carries the names it is given, one entry each", () => { const body = applyFreeTierRequestContract(CHAT_BODY(), "openai", [ - "glob", - "grep", - "read", + "edit", + "write", + "webfetch", ]) as Record; const tools = body.tools as Array<{ function: { name: string; parameters: object } }>; assert.deepEqual( tools.map((t) => t.function.name), - ["glob", "grep", "read"] + ["bash", "glob", "grep", "read", "edit", "write", "webfetch"] ); // Names only: an empty parameter object, so a borrowed name is a list entry rather than // a tool the model could usefully call. - assert.deepEqual(tools[0].function.parameters, { type: "object", properties: {} }); + assert.deepEqual(tools[4].function.parameters, { type: "object", properties: {} }); }); test("responses: several placeholder names keep the flat shape and no tool_choice", () => { const body = applyFreeTierRequestContract(RESPONSES_BODY(), "openai-responses", [ - "glob", - "grep", + "edit", + "write", ]) as Record; const tools = body.tools as Array<{ type: string; name: string }>; assert.deepEqual( tools.map((t) => t.name), - ["glob", "grep"] + ["bash", "glob", "grep", "read", "edit", "write"] ); + for (const tool of tools) assert.equal(tool.type, "function"); assert.equal("tool_choice" in body, false); }); @@ -130,10 +135,10 @@ test("an empty tools array counts as no tools", () => { string, unknown >; - assert.equal((body.tools as unknown[]).length, 1); + assert.equal((body.tools as unknown[]).length, 5); }); -test("responses: the placeholder tool is flat and tool_choice stays absent", () => { +test("responses: the required tool list is flat and tool_choice stays absent", () => { // The upstream Responses surface rejects tool_choice with a 400 invalid_request_error // (measured on both "none" and {type:"none"}), so the contract must not send it there. const body = applyFreeTierRequestContract(RESPONSES_BODY(), "openai-responses") as Record< @@ -142,13 +147,16 @@ test("responses: the placeholder tool is flat and tool_choice stays absent", () >; assert.equal(body.stream, true); const tools = body.tools as Array<{ type: string; name: string }>; - assert.equal(tools.length, 1); + assert.equal(tools.length, 5); assert.equal(tools[0].type, "function"); - assert.equal(tools[0].name, "_noop"); + assert.deepEqual( + tools.map((t) => t.name), + ["bash", "glob", "grep", "read", "_noop"] + ); assert.equal("tool_choice" in body, false); }); -test("client-supplied tools are never replaced, required placeholders are appended, and no tool_choice is imposed", () => { +test("client-supplied tools are never replaced, required names are appended, and no tool_choice is imposed", () => { const clientTools = [ { type: "function", function: { name: "search", parameters: { type: "object" } } }, ]; @@ -157,16 +165,23 @@ test("client-supplied tools are never replaced, required placeholders are append "openai" ) as Record; const tools = body.tools as Array<{ type: string; function?: { name: string } }>; - assert.equal(tools.length, 2); + assert.equal(tools.length, 6); assert.equal(tools[0].function?.name, "search"); - assert.equal(tools[1].function?.name, "_noop"); + assert.deepEqual( + tools.slice(1).map((t) => t.function?.name), + ["bash", "glob", "grep", "read", "_noop"] + ); assert.equal("tool_choice" in body, false); assert.equal(body.stream, true); }); -test("when client-supplied tools already include placeholder tools, nothing extra is added", () => { +test("when client-supplied tools already carry the required names, nothing extra is added", () => { const clientTools = [ { type: "function", function: { name: "search", parameters: { type: "object" } } }, + { type: "function", function: { name: "bash", parameters: { type: "object" } } }, + { type: "function", function: { name: "glob", parameters: { type: "object" } } }, + { type: "function", function: { name: "grep", parameters: { type: "object" } } }, + { type: "function", function: { name: "read", parameters: { type: "object" } } }, { type: "function", function: { name: "_noop", parameters: { type: "object" } } }, ]; const body = applyFreeTierRequestContract( @@ -191,9 +206,11 @@ test("when client-supplied tools are present, multiple configured placeholders a const tools = body.tools as Array<{ type: string; function?: { name: string } }>; assert.equal(tools.length, 7); assert.equal(tools[0].function?.name, "run_code"); + // The configured names already covered by the quartet are not duplicated: the quartet is + // declared once, and only `edit` and `write` are added on top of it. assert.deepEqual( tools.slice(1).map((t) => t.function?.name), - ["glob", "grep", "read", "edit", "write", "bash"] + ["bash", "glob", "grep", "read", "edit", "write"] ); }); @@ -208,7 +225,7 @@ test("a client tool_choice is preserved", () => { test("applying the contract twice does not duplicate the placeholder tool", () => { const once = applyFreeTierRequestContract(CHAT_BODY(), "openai"); const twice = applyFreeTierRequestContract(once, "openai") as Record; - assert.equal((twice.tools as unknown[]).length, 1); + assert.equal((twice.tools as unknown[]).length, 5); }); test("an unknown body format only gets the streaming flag", () => { @@ -339,7 +356,7 @@ test("transformRequest: the contract is applied for a free model and skipped for null as never ) as Record; assert.equal(free.stream, true); - assert.equal((free.tools as unknown[]).length, 1); + assert.equal((free.tools as unknown[]).length, 5); const paid = executor.transformRequest( "gpt-5.6-luna", @@ -381,7 +398,11 @@ test("a JSON caller gets a JSON body back even though the upstream request was s })) as { response: Response }; assert.equal(seen[0]?.stream, true, "the upstream request was streamed"); - assert.equal((seen[0]?.tools as unknown[]).length, 1, "and carried the placeholder tool"); + assert.equal( + (seen[0]?.tools as unknown[]).length, + 5, + "and carried the required tool list plus the placeholder" + ); assert.match(result.response.headers.get("content-type") ?? "", /application\/json/); const json = (await result.response.json()) as { choices?: Array<{ message?: { content?: string } }>; @@ -511,7 +532,7 @@ test("with nothing observed and nothing configured, the built-in placeholder is const tools = (body as Record).tools as Array<{ function: { name: string } }>; assert.deepEqual( tools.map((t) => t.function.name), - ["_noop"] + ["bash", "glob", "grep", "read", "_noop"] ); // Nothing was borrowed, so a refusal here must not be charged against the store. assert.equal(attempt?.borrowed, false); @@ -522,8 +543,8 @@ test("an accepted request teaches the names it carried, and a later bare request const withTools = { ...CHAT_BODY(), tools: [ - { type: "function", function: { name: "glob" } }, - { type: "function", function: { name: "grep" } }, + { type: "function", function: { name: "webfetch" } }, + { type: "function", function: { name: "websearch" } }, ], }; const first = prepareFreeTierRequest( @@ -533,11 +554,11 @@ test("an accepted request teaches the names it carried, and a later bare request "opencode", "nemotron-3.5-lightning-free" ); - assert.deepEqual(first.attempt?.clientToolNames, ["glob", "grep"]); + assert.deepEqual(first.attempt?.clientToolNames, ["webfetch", "websearch"]); noteFreeTierOutcome(first.attempt, true); assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), [ - "glob", - "grep", + "webfetch", + "websearch", ]); const second = prepareFreeTierRequest( @@ -550,9 +571,10 @@ test("an accepted request teaches the names it carried, and a later bare request const tools = (second.body as Record).tools as Array<{ function: { name: string }; }>; + // The quartet is mandatory on every gated body, so the borrowed names ride along with it. assert.deepEqual( tools.map((t) => t.function.name), - ["glob", "grep"] + ["bash", "glob", "grep", "read", "webfetch", "websearch"] ); assert.equal(second.attempt?.borrowed, true); }); @@ -694,8 +716,8 @@ test("a session's service request borrows the list its own build request declare { ...CHAT_BODY(), tools: [ - { type: "function", function: { name: "glob" } }, - { type: "function", function: { name: "grep" } }, + { type: "function", function: { name: "webfetch" } }, + { type: "function", function: { name: "websearch" } }, ], }, "openai", @@ -719,7 +741,7 @@ test("a session's service request borrows the list its own build request declare }>; assert.deepEqual( tools.map((t) => t.function.name), - ["glob", "grep"] + ["bash", "glob", "grep", "read", "webfetch", "websearch"] ); assert.equal(title.attempt?.borrowed, true); }); @@ -761,7 +783,7 @@ test("a session's own list wins over the shared one", () => { }>; assert.deepEqual( tools.map((t) => t.function.name), - ["own"] + ["bash", "glob", "grep", "read", "own"] ); }); @@ -791,7 +813,7 @@ test("a session that has declared nothing yet falls back to the shared entry", ( }>; assert.deepEqual( tools.map((t) => t.function.name), - ["shared"] + ["bash", "glob", "grep", "read", "shared"] ); }); @@ -803,7 +825,7 @@ test("a client session id is read case-insensitively, a synthesized one is not b assert.equal(clientSuppliedOpencodeSession(undefined), undefined); }); -test("mergeClientToolsWithObserved: keeps client tools first and appends missing observed names with empty schemas [free-tier-observed-tools]", () => { +test("mergeClientToolsWithObserved: keeps client tools first and appends the required names with empty schemas [free-tier-observed-tools]", () => { _resetToolObservationForTests(); const body = { ...CHAT_BODY(), @@ -819,23 +841,48 @@ test("mergeClientToolsWithObserved: keeps client tools first and appends missing const tools = (merged as Record).tools as Array<{ function: { name: string; parameters: object }; }>; + // The client keeps its two entries first; the two quartet members it does not spell are + // restored before the observed name. assert.deepEqual( tools.map((t) => t.function.name), - ["glob", "read", "edit"] + ["glob", "read", "bash", "grep", "edit"] ); // Appended entries are list entries, not callable tools. - assert.deepEqual(tools[2].function.parameters, { type: "object", properties: {} }); + assert.deepEqual(tools[4].function.parameters, { type: "object", properties: {} }); }); -test("mergeClientToolsWithObserved: leaves the body untouched when nothing observed [free-tier-observed-tools]", () => { +test("mergeClientToolsWithObserved: restores the quartet when missing, keeps an already-complete body [free-tier-observed-tools]", () => { _resetToolObservationForTests(); - const body = { + const complete = { ...CHAT_BODY(), - tools: [{ type: "function", function: { name: "glob", parameters: { type: "object" } } }], + tools: [ + { type: "function", function: { name: "bash", parameters: { type: "object" } } }, + { type: "function", function: { name: "glob", parameters: { type: "object" } } }, + { type: "function", function: { name: "grep", parameters: { type: "object" } } }, + { type: "function", function: { name: "read", parameters: { type: "object" } } }, + ], }; assert.equal( - mergeClientToolsWithObserved(body, "openai", "opencode", "big-pickle", undefined, []), - body + mergeClientToolsWithObserved(complete, "openai", "opencode", "big-pickle", undefined, []), + complete, + "nothing observed and nothing missing: the caller can skip the retry on identity" + ); + + const partial = { + ...CHAT_BODY(), + tools: [{ type: "function", function: { name: "glob", parameters: { type: "object" } } }], + }; + const merged = mergeClientToolsWithObserved( + partial, + "openai", + "opencode", + "big-pickle", + undefined, + [] + ) as Record; + assert.deepEqual( + (merged.tools as Array<{ function: { name: string } }>).map((t) => t.function.name), + ["glob", "bash", "grep", "read"] ); }); diff --git a/tests/unit/opencode-quota-retry-own-tools.test.ts b/tests/unit/opencode-quota-retry-own-tools.test.ts index f45f32917856..feea9ca1d80f 100644 --- a/tests/unit/opencode-quota-retry-own-tools.test.ts +++ b/tests/unit/opencode-quota-retry-own-tools.test.ts @@ -138,7 +138,7 @@ for (const status of [403, 451]) { ); assert.equal(response.status, 200); assert.equal(bodies.length, 2); - assert.deepEqual(toolNamesOf(bodies[0]), ["read", "bash"]); + assert.deepEqual(toolNamesOf(bodies[0]), ["read", "bash", "glob", "grep"]); assert.ok(toolNamesOf(bodies[1]).includes("read")); assert.ok(toolNamesOf(bodies[1]).includes("edit")); await response.body?.cancel(); diff --git a/tests/unit/opencode-request-shape-retry.test.ts b/tests/unit/opencode-request-shape-retry.test.ts index f3ea285079dc..0b1f0c1da7b0 100644 --- a/tests/unit/opencode-request-shape-retry.test.ts +++ b/tests/unit/opencode-request-shape-retry.test.ts @@ -505,7 +505,7 @@ test("without an origin, preparing a request behaves exactly as before", () => { const { attempt, body: out } = prepareFreeTierRequest(body, null, "zen", "opencode", MODEL); assert.ok(attempt); assert.equal(attempt.probe, false, "no replay can happen without an origin to key it on"); - assert.equal(((out as Body).tools as unknown[]).length, 1); + assert.equal(((out as Body).tools as unknown[]).length, 5, "the required tool list was added"); noteFreeTierOutcome(attempt, false); }); diff --git a/tests/unit/opencode-standalone-free-tier-16571.test.ts b/tests/unit/opencode-standalone-free-tier-16571.test.ts new file mode 100644 index 000000000000..1378cf486b62 --- /dev/null +++ b/tests/unit/opencode-standalone-free-tier-16571.test.ts @@ -0,0 +1,321 @@ +/** + * OpenCode Free (`opencode`) and OpenCode Zen (`opencode-zen`) used STANDALONE (#16571, #16328). + * + * Both providers are served by `https://opencode.ai/zen/v1`, and on that surface the upstream + * refuses any free-tier request that does not match the OpenCode client's own contract with + * `403 FreeTierError` ("OpenCode's free tier can only be used from within OpenCode"). Two of + * the four legs of that contract are still missing in a standalone install: + * + * 1. **The tools list is a fingerprint, not just a non-empty array.** Measured live on + * 2026-10-02/03 against `/chat/completions` and `/responses` (9 gated free models, 121 + * records, independently reproduced in #15322): an empty list, a lone `_noop` + * placeholder, a single client tool, or the capitalised `Bash` spelling are all answered + * 403; the lowercase quartet `bash`, `glob`, `grep`, `read` is answered 200 (the minimal + * measured pair `bash` + `read` also passes — the quartet is kept as a safety margin). + * Order is irrelevant and extra tools alongside the quartet are accepted, so a caller's + * own tool list is kept as-is instead of being renamed. + * + * 2. **The session id's value is validated, not only its shape.** The 12 hex digits decode + * through the client's `Identifier.descending()` layout, where the low 12 bits carry + * `0xFFF - counter` (counter is the within-millisecond sequence, 0-63): only a low-12 + * landing in `0xFC0-0xFFF` passes (measured 2026-10-13/14, #16328). A shape-valid id with + * a foreign encoding - a raw hash, an ascending id, a counter out of the window - is + * answered 403 even though `OPENCODE_SESSION_PATTERN` accepts it. + * + * The other two legs (`stream: true`, `User-Agent: opencode/<>= 1.17>`) are covered by + * `opencode-free-tier-request-contract.test.ts`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + OPENCODE_FREE_TIER_FINGERPRINT_TOOLS, + applyFreeTierRequestContract, + mergeClientToolsWithObserved, + prepareFreeTierRequest, +} from "../../open-sse/executors/opencodeFreeTierContract.ts"; +import { + OPENCODE_SESSION_PATTERN, + OPENCODE_SESSION_VALUE_PATTERN, + forwardOpencodeClientHeaders, + isCanonicalOpencodeSessionValue, +} from "../../open-sse/utils/opencodeHeaders.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; + +/** The lowercase quartet the upstream fingerprints (measured 2026-10-02/03, #15322). */ +const QUARTET = ["bash", "glob", "grep", "read"]; + +/** A session whose 12-hex time part keeps the counter window `0xFC0-0xFFF` (counter 0-63). */ +const CANONICAL_SESSION_VALUE_RE = /^ses_[0-9a-f]{9}f[c-f][0-9a-f][0-9A-Za-z]{14}$/; + +/** Same shape, foreign encoding: the low 12 bits read `9ab`, outside the accepted window. */ +const FOREIGN_SESSION = "ses_0123456789ababcdefghijklmn"; + +const CHAT_BODY = () => ({ + model: "nemotron-3.5-lightning-free", + messages: [{ role: "user", content: "hi" }], +}); +const RESPONSES_BODY = () => ({ + model: "muse-spark-1.3-contributor-free", + input: [{ role: "user", content: "hi" }], +}); + +function chatToolNames(body: unknown): string[] { + const tools = (body as { tools?: Array<{ function?: { name?: string }; name?: string }> }).tools; + return (tools ?? []).map((tool) => tool.function?.name ?? tool.name ?? ""); +} + +test("the constants above are the ones the fix ships (no drift between test and source)", () => { + assert.deepEqual([...OPENCODE_FREE_TIER_FINGERPRINT_TOOLS], QUARTET); + assert.equal(OPENCODE_SESSION_VALUE_PATTERN.source, CANONICAL_SESSION_VALUE_RE.source); + const decodable = "ses_abcdef012fcdZYXWVUTSRQPONM"; + assert.equal(isCanonicalOpencodeSessionValue(decodable), true); + assert.equal(isCanonicalOpencodeSessionValue(FOREIGN_SESSION), false); + assert.equal(isCanonicalOpencodeSessionValue("ses_0123456789ab"), false); + assert.equal(isCanonicalOpencodeSessionValue(undefined), false); +}); + +test("a tool-less standalone request is brought up to the canonical lowercase quartet", () => { + const body = applyFreeTierRequestContract(CHAT_BODY(), "openai") as Record; + const names = chatToolNames(body); + for (const name of QUARTET) { + assert.ok( + names.includes(name), + `the contract must declare \`${name}\`, got ${names.join(",")}` + ); + } + // The legacy placeholder is additive: the upstream accepts extra tools alongside the + // quartet, so an install that configured it keeps seeing it. + assert.equal(new Set(names).size, names.length, "no duplicate entries"); +}); + +test("the Responses surface declares the same quartet in its flat shape", () => { + const body = applyFreeTierRequestContract(RESPONSES_BODY(), "openai-responses") as Record< + string, + unknown + >; + const tools = body.tools as Array>; + const names = tools.map((tool) => String(tool.name)); + for (const name of QUARTET) { + assert.ok(names.includes(name), `the Responses surface must declare \`${name}\``); + } + for (const tool of tools) { + assert.equal(tool.type, "function"); + assert.equal(typeof tool.parameters, "object"); + } +}); + +test("a caller's own tools are kept as they are, quartet added alongside", () => { + const clientTools = [ + { type: "function", function: { name: "Bash", parameters: { type: "object" } } }, + { type: "function", function: { name: "Read", parameters: { type: "object" } } }, + ]; + const body = applyFreeTierRequestContract( + { ...CHAT_BODY(), tools: clientTools }, + "openai" + ) as Record; + const names = chatToolNames(body); + // The caller's own spelling is never renamed away — the upstream accepts extra entries and + // the client must still recognise the names it declared (#15322). + assert.ok(names.includes("Bash")); + assert.ok(names.includes("Read")); + for (const name of QUARTET) assert.ok(names.includes(name), `missing \`${name}\``); +}); + +test("applying the contract twice does not duplicate the quartet", () => { + const once = applyFreeTierRequestContract(CHAT_BODY(), "openai") as Record; + const twice = applyFreeTierRequestContract(once, "openai") as Record; + const names = chatToolNames(twice); + assert.equal(names.filter((name) => name === "bash").length, 1); + assert.equal( + names.length, + chatToolNames(once).length, + "idempotent: the second pass adds nothing" + ); +}); + +test("the refusal-retry merge also ends on the quartet", () => { + const clientTools = [ + { type: "function", function: { name: "webfetch", parameters: { type: "object" } } }, + ]; + const merged = mergeClientToolsWithObserved( + { ...CHAT_BODY(), tools: clientTools }, + "openai", + "opencode-zen", + "nemotron-3.5-lightning-free", + undefined, + [] + ) as Record; + const names = chatToolNames(merged); + for (const name of QUARTET) assert.ok(names.includes(name), `missing \`${name}\``); + assert.ok(names.includes("webfetch"), "the client's own tool stays declared"); +}); + +test("prepareFreeTierRequest brings both standalone providers up to the quartet", () => { + for (const provider of ["opencode", "opencode-zen"]) { + const { body } = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + provider, + "nemotron-3.5-lightning-free" + ); + const names = chatToolNames(body); + for (const name of QUARTET) { + assert.ok(names.includes(name), `${provider}: missing \`${name}\``); + } + assert.equal((body as Record).stream, true); + } +}); + +test("a shape-valid session with a foreign encoding is re-minted inside the counter window", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": FOREIGN_SESSION }, + { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + reencodeSession: true, + } + ); + const session = headers["x-opencode-session"]; + assert.notEqual(session, FOREIGN_SESSION, "a foreign encoding must not reach the gate"); + assert.match(session ?? "", OPENCODE_SESSION_PATTERN); + assert.match(session ?? "", CANONICAL_SESSION_VALUE_RE); +}); + +test("re-minting is deterministic, so one conversation keeps one upstream session", () => { + const once: Record = {}; + const twice: Record = {}; + const options = { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + reencodeSession: true, + }; + forwardOpencodeClientHeaders(once, { "x-opencode-session": FOREIGN_SESSION }, options); + forwardOpencodeClientHeaders(twice, { "x-opencode-session": FOREIGN_SESSION }, options); + assert.equal(once["x-opencode-session"], twice["x-opencode-session"]); + // A different conversation keeps its own session (no single pinned identity). + const other: Record = {}; + forwardOpencodeClientHeaders( + other, + { "x-opencode-session": "ses_0123456789abzyxwvutsrqponm" }, + options + ); + assert.notEqual(other["x-opencode-session"], once["x-opencode-session"]); +}); + +test("a genuine descending session is kept verbatim", () => { + const first: Record = {}; + forwardOpencodeClientHeaders( + first, + {}, + { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + reencodeSession: true, + } + ); + const minted = first["x-opencode-session"] ?? ""; + assert.match(minted, CANONICAL_SESSION_VALUE_RE, "a synthesized id must satisfy the value gate"); + + const second: Record = {}; + forwardOpencodeClientHeaders( + second, + { "x-opencode-session": minted }, + { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + reencodeSession: true, + } + ); + assert.equal(second["x-opencode-session"], minted, "no churn for an already-valid value"); +}); + +test("outside the gate the caller's own session value is left alone (opencode-go)", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": FOREIGN_SESSION }, + { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + reencodeSession: false, + } + ); + assert.equal(headers["x-opencode-session"], FOREIGN_SESSION); +}); + +test("buildHeaders: the executor re-encodes a foreign session on a gated model only", () => { + const gated = new OpencodeExecutor("opencode-zen").buildHeaders( + null, + false, + { "x-opencode-session": FOREIGN_SESSION }, + "nemotron-3.5-lightning-free" + ); + assert.match(gated["x-opencode-session"] ?? "", CANONICAL_SESSION_VALUE_RE); + + const paid = new OpencodeExecutor("opencode-zen").buildHeaders( + null, + false, + { "x-opencode-session": FOREIGN_SESSION }, + "gpt-5.6-luna" + ); + assert.equal(paid["x-opencode-session"], FOREIGN_SESSION); +}); + +test("buildHeaders: the noauth `opencode` provider is gated the same way", () => { + const headers = new OpencodeExecutor("opencode").buildHeaders( + null, + false, + { "x-opencode-session": FOREIGN_SESSION }, + "big-pickle" + ); + assert.match(headers["x-opencode-session"] ?? "", CANONICAL_SESSION_VALUE_RE); +}); + +test("a standalone tool-less request leaves with all four legs of the contract", async () => { + // The reproduction, end to end: a generic client (no OpenCode CLI) asks for a free model, + // sends no tools and no OpenCode headers, and the executor has to bring the request up to + // the upstream contract by itself — the quartet, the streaming flag, the UA and a session + // whose value the gate decodes. + const executor = new OpencodeExecutor("opencode"); + const originalFetch = globalThis.fetch; + const seen: Array<{ url: string; headers: Headers; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ + url: String(input), + headers: new Headers((init?.headers as HeadersInit) ?? {}), + body: JSON.parse(String(init?.body ?? "{}")), + }); + const sse = + 'data: {"id":"gen-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}\n\n' + + 'data: {"id":"gen-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + return new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }); + }) as typeof globalThis.fetch; + + try { + const result = (await executor.execute({ + model: "big-pickle", + body: { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }, + stream: false, + signal: null, + credentials: { apiKey: null, accessToken: null, connectionId: "c" }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + })) as { response: Response }; + + assert.equal(result.response.status, 200); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, "https://opencode.ai/zen/v1/chat/completions"); + // Body legs: streamed, and every quartet name declared. + assert.equal(seen[0].body.stream, true); + const names = chatToolNames(seen[0].body); + for (const name of QUARTET) assert.ok(names.includes(name), `missing \`${name}\``); + // Header legs: a decodable session and a UA the gate accepts. + assert.match(seen[0].headers.get("x-opencode-session") ?? "", CANONICAL_SESSION_VALUE_RE); + assert.match(seen[0].headers.get("user-agent") ?? "", /^opencode\/\d+\.\d+/); + } finally { + globalThis.fetch = originalFetch; + } +}); From 1d635e19808cfb29226f77258571f8e6fba83ac3 Mon Sep 17 00:00:00 2001 From: Moseyuh333 <148680980+Moseyuh333@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:41:52 +0700 Subject: [PATCH 2/2] chore(opencode): keep the fix inside the file-size and i18n gates - Drop the stale `void model;` in `OpencodeExecutor.buildHeaders`: `model` is already consumed by the free-tier scope check above it, and the dead statement was the line that pushed the file over its `check:file-size` freeze. - Revert the `docs/reference/FREE_TIERS.md` edit to its base text. A core-doc change owes `npm run i18n:run --files=` across 65 mirrors and the translation backend key is absent here, so the doc correction belongs in its own change; the measured contract stays documented in the changelog fragment and in the executor comments. - Rename the changelog fragment to the issue number (16571). No behaviour change: `buildHeaders` output is untouched (opencode suites green). --- ...=> 16571-opencode-free-tier-standalone.md} | 0 docs/reference/FREE_TIERS.md | 22 ++----------------- open-sse/executors/opencode.ts | 2 -- 3 files changed, 2 insertions(+), 22 deletions(-) rename changelog.d/fixes/{0000-opencode-free-tier-standalone.md => 16571-opencode-free-tier-standalone.md} (100%) diff --git a/changelog.d/fixes/0000-opencode-free-tier-standalone.md b/changelog.d/fixes/16571-opencode-free-tier-standalone.md similarity index 100% rename from changelog.d/fixes/0000-opencode-free-tier-standalone.md rename to changelog.d/fixes/16571-opencode-free-tier-standalone.md diff --git a/docs/reference/FREE_TIERS.md b/docs/reference/FREE_TIERS.md index b848ecefc733..c3f072a64783 100644 --- a/docs/reference/FREE_TIERS.md +++ b/docs/reference/FREE_TIERS.md @@ -292,26 +292,8 @@ sentence _"OpenCode's free tier can only be used from within OpenCode"_. This is request-scoped refusal (same verdict on every account for the same request shape), not a model ban or connection cooldown — OmniRoute classifies it as `project_route_error`, skips model lockout / cooldown, and (on the synthetic `noauth` path) pauses auto-combo re-selection -for a short TTL. - -The gate has four legs, and the last two are narrower than they look: - -1. `stream: true`. -2. A `tools` list that declares the lowercase quartet `bash`, `glob`, `grep`, `read`. It is a - fingerprint, not a non-empty check: measured live on 2026-10-02/03 over `/chat/completions` - and `/responses` on 9 gated free models, an empty list, a lone `_noop` placeholder, a - single client tool and the capitalised `Bash` spelling are all answered 403, while the - quartet is answered 200. Order is irrelevant and extra tools beside the quartet are - accepted, so a caller's own entries are never renamed away. -3. An `x-opencode-session` whose 12 hex digits decode through the client's - `Identifier.descending()` layout into the counter window `0xFC0-0xFFF` (counter 0-63). A - shape-valid id from another scheme — a raw hash, an ascending id, a counter out of window — - is refused like a missing one (measured 2026-10-13/14). -4. A `User-Agent` containing `opencode/= 1.17>`; a lower version answers 426. - -OmniRoute synthesizes all four for a caller that arrives without them -(`opencodeFreeTierContract.ts` for the body, `opencodeHeaders.ts` for the session), which is -what makes the provider usable from any client rather than only from the OpenCode CLI. +for a short TTL. Ship requests that carry a non-empty tool list, `stream: true`, and the +OpenCode session/UA headers (`opencodeFreeTierContract.ts`) or expect the 403. ## What changed since the shipped catalog (`freeNote`) diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 0d2f7edb4c59..78c3d70f09d9 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1370,8 +1370,6 @@ export class OpencodeExecutor extends BaseExecutor { // to overwrite is accepted on that surface (measured 2026-09-17, 200 on // muse-spark-1.3-contributor-free via /v1/responses). - void model; - return headers; }