diff --git a/.env.example b/.env.example index 99b6adabf9..1a11f36562 100644 --- a/.env.example +++ b/.env.example @@ -763,14 +763,26 @@ NEXT_PUBLIC_CLOUD_URL= # OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of # opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers. # When your clients don't already send them, set this to synthesize the CLI headers -# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on -# absent keys. OFF by default — forward-only is safer when clients already send them. +# (User-Agent, x-opencode-client, x-opencode-project, canonical request/session ids) on +# absent keys. ON by default — a client value always wins, these only fill gaps. # Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT / -# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default). +# OPENCODE_PROJECT (defaults: opencode/1.18.31 / desktop / global). #OPENCODE_SYNTHESIZE_CLI_HEADERS=true -#OPENCODE_USER_AGENT=opencode-cli/1.0.0 -#OPENCODE_CLIENT=cli -#OPENCODE_PROJECT=default +#OPENCODE_USER_AGENT=opencode/1.18.31 +#OPENCODE_CLIENT=desktop +#OPENCODE_PROJECT=global + +# Keyless OpenCode models are answered only when the request declares a non-empty tool +# list, and the upstream inspects which names it carries. OmniRoute reuses the list a +# request of the same conversation was last seen getting through, so a request that +# carries none — a title or a summary — goes out with the list its own client already +# declared. Set to off to stop adjusting request bodies entirely; headers are unaffected. +#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off + +# Tool names to declare when nothing has been observed yet for a model, comma-separated. +# Empty falls back to a single placeholder the model is told not to call. Only useful on +# an install where no client sends tools, since there is then nothing to learn from. +#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read # Ollama Cloud quota scraping. Prefer configuring this per connection in # Dashboard → Providers → Ollama Cloud. The cookie is sensitive. diff --git a/changelog.d/fixes/14013-opencode-free-tier-request-contract.md b/changelog.d/fixes/14013-opencode-free-tier-request-contract.md new file mode 100644 index 0000000000..dd71a997d0 --- /dev/null +++ b/changelog.d/fixes/14013-opencode-free-tier-request-contract.md @@ -0,0 +1 @@ +- **fix(opencode):** keyless OpenCode models answer again instead of returning `403` — requests now carry a versioned OpenCode user-agent, canonical session and request ids derived from the existing conversation fingerprint, a streamed upstream request, and a tool list. The upstream inspects which tool names a request declares, so rather than pinning a list, OmniRoute reuses the one a request of the same conversation was last seen getting through: a title or a summary, which its client sends without tools, goes out with the list that client already declared. Two opt-outs (`OPENCODE_FREE_TIER_REQUEST_CONTRACT`, `OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS`) ([#14013](https://github.com/diegosouzapw/OmniRoute/pull/14013)) — thanks @maxmad64bis (with thanks to @AStupidBear for the identity-header work in #13937) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 796408acf9..a87038f5f7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -345,9 +345,11 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | | `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/usage` | `open-sse/services/opencodeQuotaFetcher.ts` | Official API-key-authenticated OpenCode Go usage endpoint used by the Usage page. Override for relays / test fixtures. | | `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `true` | `open-sse/executors/opencode.ts` | Synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). On by default since #10571; opt out with `false`/`0`/`no`/`off`. | -| `OPENCODE_USER_AGENT` | `opencode` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | +| `OPENCODE_USER_AGENT` | `opencode/1.18.31` | `open-sse/utils/opencodeHeaders.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. On a keyless request the upstream gates, a configured value that does not carry `opencode/= 1.17>` is replaced by this default rather than refused. | | `OPENCODE_CLIENT` | `desktop` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | | `OPENCODE_PROJECT` | `global` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | +| `OPENCODE_FREE_TIER_REQUEST_CONTRACT` | _(unset)_ | `open-sse/executors/opencodeFreeTierContract.ts` | Set to `off` to stop adjusting the body of keyless OpenCode requests (streaming flag and tool list). Headers are still applied. Read per request, so a change takes effect immediately. | +| `OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS` | _(unset)_ | `open-sse/executors/opencodeFreeTierContract.ts` | Comma-separated tool names to declare on a keyless OpenCode request that carries none, when nothing has been observed yet for that model. Empty falls back to a single placeholder the model is told not to call. At most 32 entries, `[A-Za-z_][A-Za-z0-9_-]{0,63}`; invalid entries are ignored. | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | | `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OLLAMA_CLOUD_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 01909f4f5e..5cb2720dd1 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { BaseExecutor, type ExecuteInput, @@ -16,7 +15,11 @@ import { runWithDirectFetchContext, runWithProxyContext, } from "../utils/proxyFetch.ts"; -import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; +import { + clientSuppliedOpencodeSession, + forwardOpencodeClientHeaders, + resolveOpencodeCliDefaults, +} from "../utils/opencodeHeaders.ts"; import { type AccountProxyConfig, type RotatableAccount, @@ -33,6 +36,19 @@ import { proxyKeyOf, isOpencodeUserBlocked, } from "./opencodeGeoBlock.ts"; +import { + isGatedFreeTierRequest, + isPremiumOpencodeModel, + noteFreeTierOutcome, + prepareFreeTierRequest, + rebuildJsonFromForcedStream, + surfaceFromBaseUrl, + type FreeTierContractAttempt, +} from "./opencodeFreeTierContract.ts"; + +// Re-exported: the free-model catalog moved to the contract module (it decides whether the +// contract applies), and existing importers keep resolving it from the executor. +export { isPremiumOpencodeModel }; import { guardResponsesStall, isResponsesFirstByteTimeout, @@ -77,31 +93,6 @@ interface OpencodeAccountState extends RotatableAccount { const EFFORT_LEVELS = ["none", "low", "high", "max"] as const; -/** - * Models that work WITHOUT any API key on the free/noauth opencode tier. - * - * The upstream free tier rotates frequently — when a `-free` suffix model is - * delisted upstream, the upstream returns "Model X is not supported" (a separate - * issue from this gate). The set is defined by two data sources: - * - * 1. **Known free models** — models explicitly listed in the noauth - * `opencode` provider registry (`open-sse/config/providers/registry/opencode/index.ts`). - * These are the canonical free models. `deepseek-v4-flash-free` appears in both - * the noauth AND the zen registry (it is free on both tiers). - * 2. **`-free` suffix** — any model whose id ends in `-free`. This automatically - * covers upstream free-tier additions without a code deploy. - * - * For `opencode-go`, there is no free tier — ALL models require an API key. - */ -const OPENCODE_FREE_MODELS = new Set([ - "big-pickle", - "deepseek-v4-flash-free", - "mimo-v2.5-free", - "hy3-free", - "nemotron-3-ultra-free", - "north-mini-code-free", -]); - /** * Models on opencode-go that support effort-tier aliases. Each entry maps the * canonical base id to the set of effort suffixes the upstream supports. @@ -153,25 +144,6 @@ export function parseEffortLevel(model: string): { baseModel: string; effort: st return null; } -/** - * Determine whether a model requires an API key on the given opencode provider. - * - * - `opencode-go`: ALL models require a key (no free tier). - * - `opencode` / `opencode-zen`: premium = any model NOT in the free set (known - * free models OR ending in `-free`). - * - Unknown models are assumed premium (fail-safe). - */ -export function isPremiumOpencodeModel(model: string, provider: string): boolean { - // opencode-go has no free tier — every model requires a key. - if (provider === "opencode-go") return true; - - // Models ending in `-free` are always free on the noauth/zen tier. - if (model.endsWith("-free")) return false; - - // Check the known free model catalog. - return !OPENCODE_FREE_MODELS.has(model); -} - /** * Resolves the registry `targetFormat` for a model, aliasing `provider` first. * @@ -303,6 +275,10 @@ export class OpencodeExecutor extends BaseExecutor { } _requestFormat: string | null = null; + private _contractAttempt: FreeTierContractAttempt | null = null; + /** Set in buildHeaders, which execute() runs before transformRequest. */ + private _clientSession: string | undefined; + private _surface = () => surfaceFromBaseUrl(this.config?.baseUrl); /** * Per-account rotation state, rebuilt from credentials on each request. The @@ -376,6 +352,24 @@ export class OpencodeExecutor extends BaseExecutor { * non-streaming success responses. Non-muse-spark models pass through * untouched. */ + /** + * Hand a JSON caller a JSON body even though the free-tier contract forced the upstream + * request to stream. A streaming caller, a refusal and an already-JSON body pass through. + */ + private finalizeForcedStream( + input: ExecuteInput, + result: ExecutorExecuteResult + ): ExecutorExecuteResult { + noteFreeTierOutcome(this._contractAttempt, "response" in result && !!result.response?.ok); + if (input.stream) return result; + if (!("response" in result) || !result.response) return result; + // Non-null exactly when the contract applied: stands in for the old surface/model guard. + if (!this._contractAttempt) return result; + const model = this._contractAttempt.model; + const response = rebuildJsonFromForcedStream(result.response, this._requestFormat, model); + return response === result.response ? result : { ...result, response }; + } + private normalizeMuseSparkResponse( input: ExecuteInput, result: ExecutorExecuteResult @@ -569,9 +563,9 @@ export class OpencodeExecutor extends BaseExecutor { "OPENCODE", `${cid}upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); - return this.normalizeMuseSparkResponse( + return this.finalizeForcedStream( input, - await guardStall(await super.execute(input)) + this.normalizeMuseSparkResponse(input, await guardStall(await super.execute(input))) ); } log?.debug?.( @@ -580,7 +574,7 @@ export class OpencodeExecutor extends BaseExecutor { ); } } - return this.normalizeMuseSparkResponse(input, single); + return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, single)); } // This loop only ever dispatches through super.execute() (the HTTP request @@ -906,7 +900,7 @@ export class OpencodeExecutor extends BaseExecutor { } markOutcome(account, result.response); - return this.normalizeMuseSparkResponse(input, result); + return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, result)); } // The loop exhausted without a result. If it's because every remaining @@ -920,9 +914,12 @@ export class OpencodeExecutor extends BaseExecutor { } // All accounts returned 429 (or errored) — surface the last response. - return this.normalizeMuseSparkResponse( + return this.finalizeForcedStream( input, - lastResult ?? (await guardStall(await super.execute(input))) + this.normalizeMuseSparkResponse( + input, + lastResult ?? (await guardStall(await super.execute(input))) + ) ); } finally { this._requestFormat = null; @@ -992,7 +989,14 @@ export class OpencodeExecutor extends BaseExecutor { headers["anthropic-version"] = "2023-06-01"; } - if (stream) { + // The free tier only answers streamed requests (measured 2026-09-17: a non-streamed + // body answers 403 FreeTierError), so a JSON client is served by streaming upstream and + // rebuilding the JSON body from the event stream — the path chatCore already takes for + // any buffered event-stream response. Announcing the stream here keeps that buffering an + // expected outcome rather than a warning. + const gatedScope = + Boolean(model) && isGatedFreeTierRequest(this._surface(), this.provider, model); + if (stream || gatedScope) { headers["Accept"] = "text/event-stream"; } @@ -1001,24 +1005,12 @@ export class OpencodeExecutor extends BaseExecutor { // OPENCODE_SYNTHESIZE_CLI_HEADERS=false. Client-supplied headers always win; // User-Agent is replaced with the CLI UA unless the client already sends one that // looks like the OpenCode CLI. Default values match 9router's proven defaults. - const synthesizeCli = !/^(0|false|no|off)$/i.test( - process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" + const cliDefaults = resolveOpencodeCliDefaults( + this.config?.id || this.provider || "opencode", + gatedScope ); - const cliDefaults = synthesizeCli - ? (() => { - const providerId = this.config?.id || this.provider || "opencode"; - const envUAKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; - return { - userAgent: - process.env[envUAKey]?.trim() || - process.env.OPENCODE_USER_AGENT?.trim() || - "opencode", - client: process.env.OPENCODE_CLIENT?.trim() || "desktop", - project: process.env.OPENCODE_PROJECT?.trim() || "global", - }; - })() - : undefined; + this._clientSession = clientSuppliedOpencodeSession(clientHeaders); if (clientHeaders || cliDefaults) { const b = body && typeof body === "object" ? (body as Record) : null; forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, { @@ -1031,6 +1023,12 @@ export class OpencodeExecutor extends BaseExecutor { messages: Array.isArray(b.messages) ? (b.messages as Array<{ role?: string; content?: unknown }>) : undefined, + // The Responses surface carries the conversation under `input`; without it the + // fingerprint collapses to the model alone and every conversation on that model + // would share one upstream session. + input: Array.isArray(b.input) + ? (b.input as Array<{ role?: string; content?: unknown }>) + : undefined, tools: Array.isArray(b.tools) ? (b.tools as Array<{ name?: string; function?: { name?: string } }>) : undefined, @@ -1039,17 +1037,10 @@ export class OpencodeExecutor extends BaseExecutor { }); } - // Muse's Responses endpoint rejects the short conversation fingerprint used - // by the Chat endpoint in practice. Keep the workaround scoped to Muse. - if ( - this._requestFormat === "openai-responses" && - model.startsWith("muse-spark") && - !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( - headers["x-opencode-session"] || "" - ) - ) { - headers["x-opencode-session"] = randomUUID(); - } + // The Muse Responses workaround that forced a UUID session here is gone: the shape it + // produced is exactly what the upstream now refuses, and the canonical session it used + // to overwrite is accepted on that surface (measured 2026-09-17, 200 on + // muse-spark-1.3-contributor-free via /v1/responses). void model; @@ -1135,6 +1126,19 @@ export class OpencodeExecutor extends BaseExecutor { ): any { let modifiedBody = super.transformRequest(model, body, stream, credentials); modifiedBody = this.applyDeepSeekJsonSchemaFallback(model, modifiedBody); + // Free-tier request contract (see opencodeFreeTierContract.ts): streaming plus a + // non-empty tools array, in the shape of the surface this model is served on. Paid + // models on the same host are not gated and stay untouched. + const prepared = prepareFreeTierRequest( + modifiedBody, + this._requestFormat ?? resolveOpencodeTargetFormat(this.provider, model), + this._surface(), + this.provider, + model, + this._clientSession + ); + modifiedBody = prepared.body; + this._contractAttempt = prepared.attempt; // 9router#1442: OpenCode upstreams (e.g. kimi-k2.6 via opencode-go) return // 400 "Extra inputs are not permitted, field: 'client_metadata'" — an // OpenAI-Codex/Claude-CLI passthrough field with no equivalent here. The diff --git a/open-sse/executors/opencodeFreeTierContract.ts b/open-sse/executors/opencodeFreeTierContract.ts new file mode 100644 index 0000000000..22d4e14b32 --- /dev/null +++ b/open-sse/executors/opencodeFreeTierContract.ts @@ -0,0 +1,373 @@ +/** + * opencodeFreeTierContract.ts — the request contract OpenCode Zen's free tier enforces. + * + * Measured against the live endpoint on 2026-09-17, on three models and both the Chat + * Completions and Responses surfaces: the upstream answers 403 FreeTierError unless the + * request carries all four of + * + * 1. `stream: true` in the body, + * 2. a non-empty `tools` array (the content is not inspected), + * 3. a session header shaped `ses_` + 12 hex + 14 base62 (the shape is checked, the + * value is not — 12 arbitrary hex digits pass), + * 4. a `User-Agent` carrying `opencode/` with version >= 1.17 (an older + * version answers 426 UpgradeRequired rather than 403). + * + * Removing any single one of the four turns a 200 into a 403. Paid models on the same + * host are not gated (a paid model without tools answers 401 CreditsError), which is why + * `requiresFreeTierRequestContract` narrows the contract to free-tier models. + * + * The module also owns the free-model catalog — the catalog is what decides whether the + * contract applies, so the two belong together and the executor imports them from here — + * and the way back: the forced stream is rebuilt into a JSON body for a caller that asked + * for JSON, reusing the shared event-stream parsers. + */ +import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "../handlers/sseParser.ts"; +import { + noteRefusedBorrowedToolNames, + recordAcceptedToolNames, + resolvePlaceholderNames, +} from "./opencodeToolObservation.ts"; + +/** + * What one gated request declared, kept until its outcome is known. + * + * `borrowed` means the placeholder came from the observation store rather than from the + * caller or the configuration, which is the only case where a refusal says anything about + * that store. + */ +export interface FreeTierContractAttempt { + readonly provider: string; + readonly model: string; + readonly session: string | undefined; + readonly borrowed: boolean; + readonly clientToolNames: readonly string[]; +} + +/** + * Models that work WITHOUT any API key on the free/noauth opencode tier. + * + * The upstream free tier rotates frequently — when a `-free` suffix model is + * delisted upstream, the upstream returns "Model X is not supported" (a separate + * issue from this gate). The set is defined by two data sources: + * + * 1. **Known free models** — models explicitly listed in the noauth + * `opencode` provider registry (`open-sse/config/providers/registry/opencode/index.ts`). + * These are the canonical free models. `deepseek-v4-flash-free` appears in both + * the noauth AND the zen registry (it is free on both tiers). + * 2. **`-free` suffix** — any model whose id ends in `-free`. This automatically + * covers upstream free-tier additions without a code deploy. + * + * For `opencode-go`, there is no free tier — ALL models require an API key. + */ +const OPENCODE_FREE_MODELS = new Set([ + "big-pickle", + "deepseek-v4-flash-free", + "mimo-v2.5-free", + "hy3-free", + "nemotron-3-ultra-free", + "north-mini-code-free", +]); + +/** + * Determine whether a model requires an API key on the given opencode provider. + * + * - `opencode-go`: ALL models require a key (no free tier). + * - `opencode` / `opencode-zen`: premium = any model NOT in the free set (known + * free models OR ending in `-free`). + * - Unknown models are assumed premium (fail-safe). + */ +export function isPremiumOpencodeModel(model: string, provider: string): boolean { + // opencode-go has no free tier — every model requires a key. + if (provider === "opencode-go") return true; + + // Models ending in `-free` are always free on the noauth/zen tier. + if (model.endsWith("-free")) return false; + + // Check the known free model catalog. + return !OPENCODE_FREE_MODELS.has(model); +} + +/** + * The upstream surface a request is served on, as told apart by the registry `baseUrl`. + * + * The gate is a property of the surface, not of billing: `/zen/v1` refuses a request that + * carries no tools, while `/zen/go/v1` refuses one that carries any (upstream + * anomalyco/opencode#44300 and #44382, "Endpoint is unavailable" for any request + * containing tools). Passing the surface in keeps this module free of registry imports and + * covers every entry of the family — including the `oc` alias, which resolves to + * `opencode` before an executor is ever picked. + */ +export type OpencodeSurface = "zen" | "go" | "other"; + +const ZEN_SURFACE_BASE_URL = "https://opencode.ai/zen/v1"; +const GO_SURFACE_BASE_URL = "https://opencode.ai/zen/go/v1"; + +/** Tell the surfaces apart by registry `baseUrl`, so provider ids and aliases stay out. */ +export function surfaceFromBaseUrl(baseUrl: string | null | undefined): OpencodeSurface { + if (baseUrl === ZEN_SURFACE_BASE_URL) return "zen"; + if (baseUrl === GO_SURFACE_BASE_URL) return "go"; + return "other"; +} + +/** + * Operator opt-out for the body half of the contract. + * + * The headers stay in place either way: they are what the upstream validates by shape, and + * they cost nothing. The body half is the half built on an observation of someone else's + * service, so it is the half worth being able to switch off without waiting for a release. + * Read per call, so a change takes effect immediately. + */ +function isBodyContractEnabled(): boolean { + return (process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT || "").trim().toLowerCase() !== "off"; +} + +/** + * Whether the upstream gates this request at all. + * + * Scope only — it ignores the opt-out on purpose, because the headers are applied either + * way: they are what the upstream validates by shape, they cost nothing, and switching + * them off with the body half would turn an opt-out into a second failure mode. + */ +export function isGatedFreeTierRequest( + surface: OpencodeSurface, + provider: string, + model: string +): boolean { + if (surface !== "zen") return false; + return !isPremiumOpencodeModel(model, provider); +} + +/** Whether the body half of the contract applies: gated, and not switched off. */ +export function requiresFreeTierRequestContract( + surface: OpencodeSurface, + provider: string, + model: string +): boolean { + return isGatedFreeTierRequest(surface, provider, model) && isBodyContractEnabled(); +} + +/** The placeholder tool name the official client uses for the same purpose. */ +const PLACEHOLDER_TOOL_NAME = "_noop"; +export const DEFAULT_PLACEHOLDER_TOOL_NAME = PLACEHOLDER_TOOL_NAME; + +/** + * Operator-supplied placeholder tool names, comma-separated. + * + * The upstream inspects which names a request declares, and what it accepts differs by + * model and moves over time (measured 2026-09-18: one made-up name is accepted on + * `big-pickle` and refused on two other free models that had accepted it the day before). + * That is an observation about someone else's service, not a fact about this project, so + * it belongs in configuration rather than in a constant that needs a release to change. + * + * Empty or unset falls back to the built-in name, so an install that sets nothing keeps + * the previous behaviour. Read per call, so a change takes effect immediately. + */ +export function configuredPlaceholderToolNames(): string[] { + const raw = process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS || ""; + const kept: string[] = []; + for (const part of raw.split(",")) { + const name = part.trim(); + if (kept.length >= 32) break; + if (!/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(name)) continue; + if (!kept.includes(name)) kept.push(name); + } + return kept; +} +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; + +/** + * An empty `tools` array counts as no tools: it is the exact shape the upstream refuses + * (upstream anomalyco/opencode#49433 reports it from the client's own compaction path), + * so it has to be filled like an absent one rather than passed through. + */ +function hasTools(body: Record): boolean { + return Array.isArray(body.tools) && body.tools.length > 0; +} + +/** + * 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. 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 + * rejects any value but "auto" (measured 2026-09-18: 400 invalid_request_error, `only + * "auto" is supported for tool_choice`), so a `tool_choice` the caller did not send is + * never added, and one the caller did send travels unchanged. Any other body format only + * gets the streaming flag: injecting a tool shape blind would be a guess. + * + * Which names go in is resolved by `resolvePlaceholderNames`, because the upstream does + * inspect them. + */ +export function applyFreeTierRequestContract( + body: T, + requestFormat: string | null, + placeholderNames: readonly string[] = [PLACEHOLDER_TOOL_NAME] +): T { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + const next: Record = { ...record, stream: true }; + + if (hasTools(next)) return next as T; + + const names = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME]; + + if (requestFormat === "openai-responses") { + next.tools = names.map((name) => ({ + type: "function", + name, + description: PLACEHOLDER_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + })); + return next as T; + } + + if (requestFormat === "openai" || requestFormat === null) { + next.tools = names.map((name) => ({ + type: "function", + function: { + name, + description: PLACEHOLDER_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + }, + })); + return next as T; + } + + return next as T; +} + +function clientToolNamesOf(body: unknown): string[] { + if (!body || typeof body !== "object" || Array.isArray(body)) return []; + const tools = (body as Record).tools; + if (!Array.isArray(tools)) return []; + const names: string[] = []; + for (const tool of tools) { + if (!tool || typeof tool !== "object") continue; + const entry = tool as { name?: unknown; function?: { name?: unknown } }; + const name = typeof entry.name === "string" ? entry.name : entry.function?.name; + if (typeof name === "string") names.push(name); + } + return names; +} + +/** + * Bring one request up to the contract and report what it ended up declaring. + * + * Single entry point for the executor: it decides whether the contract applies to this + * surface and model, resolves the placeholder names, applies the body changes, and hands + * back the attempt so the outcome can be fed to `noteFreeTierOutcome`. + */ +export function prepareFreeTierRequest( + body: T, + requestFormat: string | null, + surface: OpencodeSurface, + provider: string, + model: string, + session?: string +): { body: T; attempt: FreeTierContractAttempt | null } { + const clientToolNames = clientToolNamesOf(body); + if (!requiresFreeTierRequestContract(surface, provider, model)) return { body, attempt: null }; + const names = resolvePlaceholderNames(provider, model, session, configuredPlaceholderToolNames()); + const borrowed = clientToolNames.length === 0 && names.length > 0; + return { + body: applyFreeTierRequestContract(body, requestFormat, names), + attempt: { provider, model, session, borrowed, clientToolNames }, + }; +} + +/** + * Feed a gated request's outcome back, so the next one borrows a shape that still works. + * + * An accepted request teaches which names the upstream takes right now; a refused one only + * teaches something when the names it carried came from the store. + */ +export function noteFreeTierOutcome(attempt: FreeTierContractAttempt | null, ok: boolean): void { + if (!attempt) return; + if (ok) { + if (attempt.clientToolNames.length > 0) { + recordAcceptedToolNames( + attempt.provider, + attempt.model, + attempt.session, + attempt.clientToolNames + ); + } + return; + } + if (attempt.borrowed) { + noteRefusedBorrowedToolNames(attempt.provider, attempt.model, attempt.session); + } +} + +/** + * Rebuild a JSON body from the event stream the contract forced. + * + * The contract makes the upstream request streamed even when the caller asked for JSON, so + * the conversion back has to happen here rather than in one caller: `handleChatCore` knows + * how to buffer an event stream, but the other executor consumers (the compression judge, + * token counting) call `execute()` and then read `response.json()`. Handing them the raw + * event stream would throw on a request they explicitly asked not to stream. + * + * The conversion is LAZY: the returned body drains and parses the upstream only when the + * caller reads it, so the executor never blocks on a stream that has not arrived yet — the + * caller's own read timeout governs, and a caller that cancels pays nothing. Only a + * successful event-stream response is converted; a streaming caller, a refusal and an + * already-JSON body pass through untouched. + */ +export function rebuildJsonFromForcedStream( + response: Response, + requestFormat: string | null, + model: string +): Response { + if (!response.ok || !response.body) return response; + if (!(response.headers.get("content-type") || "").includes("text/event-stream")) { + return response; + } + const upstream = response; + let drained = false; + const body = new ReadableStream( + { + async pull(controller) { + if (drained) { + controller.close(); + return; + } + drained = true; + try { + const rawSse = await upstream.text(); + const parsed = + requestFormat === "openai-responses" + ? parseSSEToResponsesOutput(rawSse, model) + : parseSSEToOpenAIResponse(rawSse, model); + const out = parsed && typeof parsed === "object" ? JSON.stringify(parsed) : rawSse; + controller.enqueue(new TextEncoder().encode(out)); + } catch (err) { + controller.error(err); + return; + } + controller.close(); + }, + cancel(reason) { + // Only when nothing started draining: once `text()` holds the reader, cancelling the + // upstream body directly throws "ReadableStream is locked". + if (!drained && !upstream.bodyUsed && upstream.body && !upstream.body.locked) { + void upstream.body.cancel(reason); + } + }, + }, + // No pull before the caller reads: a caller that never reads (or cancels) must not make + // the executor wait on an upstream stream that may never arrive. + { highWaterMark: 0 } + ); + const headers = new Headers(response.headers); + headers.set("content-type", "application/json"); + headers.delete("content-length"); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/open-sse/executors/opencodeToolObservation.ts b/open-sse/executors/opencodeToolObservation.ts new file mode 100644 index 0000000000..2bf561d8c8 --- /dev/null +++ b/open-sse/executors/opencodeToolObservation.ts @@ -0,0 +1,163 @@ +/** + * opencodeToolObservation.ts — which tool names the gated upstream accepts right now. + * + * The free tier inspects the `tools` array, and what it accepts moves. Measured on + * 2026-09-18: one made-up tool was accepted on `big-pickle` and refused on + * `nemotron-3.5-lightning-free` and `muse-spark-1.3-contributor-free` — the same body that + * the first of those had accepted a day earlier. No published artifact carries that rule; + * the upstream npm package is a 3 KB platform dispatcher. A pinned placeholder is + * therefore a guess that goes stale between releases. + * + * The relayed traffic already carries the answer. A client request that goes out with + * tools and comes back OK proves which names the upstream accepts, for that surface and + * that model, at that moment. This module remembers those names so a later request + * carrying none can borrow them, and forgets them when a borrowed set is refused. + * + * Only names are kept — never a schema, a description, or any conversation content — and + * the placeholder rebuilt from them declares an empty parameter object, so a borrowed name + * is an entry in a list rather than a callable tool. + * + * Leaf module: no internal imports, so the executor and the contract can both use it. + */ + +/** + * Bounds are arbitrary and revisable: they cap memory, they are not a measured property of + * the upstream. Entries are small (short strings), so the ceiling is generous on purpose. + */ +const MAX_NAMES_PER_ENTRY = 32; +const MAX_ENTRIES = 64; +const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/; + +/** + * How many refusals in a row a borrowed set takes before it is dropped. + * + * One refusal is not proof: the same body was refused and then accepted on + * `muse-spark-1.3-contributor-free` minutes apart (measured 2026-09-18), so the verdict + * carries a share of noise. Dropping on the first refusal would make the store oscillate + * and fall back to a placeholder already known to be refused. Arbitrary and revisable. + */ +const REFUSALS_BEFORE_FORGETTING = 3; + +/** Insertion-ordered, so the first key `Map` yields is the least recently confirmed one. */ +const observed = new Map(); +const consecutiveRefusals = new Map(); + +/** + * Surfaces and models do not share a gate, so neither do their entries. A session id + * narrows it further: within one conversation the tools a request declares belong to the + * very client whose service request is being repaired. + */ +function keyOf(provider: string, model: string, session?: string): string { + return [provider, model, session ?? ""].join("|"); +} + +/** + * Keep the names usable as a placeholder: well-formed, unique, order preserved. The + * upstream may care about which names are present; nothing suggests it cares how many + * follow a given one, so the original order is the safest thing to replay. + */ +function sanitize(names: readonly unknown[]): string[] { + const kept: string[] = []; + for (const raw of names) { + if (kept.length >= MAX_NAMES_PER_ENTRY) break; + if (typeof raw !== "string" || !NAME_PATTERN.test(raw)) continue; + if (!kept.includes(raw)) kept.push(raw); + } + return kept; +} + +/** + * Record the tool names an accepted gated request carried. + * + * Called only for a response the upstream answered OK, so an entry is always a shape that + * worked rather than one that merely looked plausible. + */ +export function recordAcceptedToolNames( + provider: string, + model: string, + session: string | undefined, + names: readonly unknown[] +): void { + const kept = sanitize(names); + if (kept.length === 0) return; + const frozen = Object.freeze(kept); + // Both scopes are written: the session entry serves the conversation it came from, the + // model entry serves a conversation that has not yet sent a request carrying tools. + for (const key of session + ? [keyOf(provider, model, session), keyOf(provider, model)] + : [keyOf(provider, model)]) { + // Re-insert so a confirmed entry becomes the most recent one for eviction. + observed.delete(key); + observed.set(key, frozen); + consecutiveRefusals.delete(key); + } + while (observed.size > MAX_ENTRIES) { + const oldest = observed.keys().next(); + if (oldest.done) break; + observed.delete(oldest.value); + consecutiveRefusals.delete(oldest.value); + } +} + +/** + * Count one refusal against a borrowed entry, and drop it once the streak is long enough. + * + * Called only when a request that BORROWED this entry was refused. A refusal on a request + * carrying the client's own tools says nothing about the entry, and a refusal caused by + * the session or the user-agent says nothing about tools at all — counting either would + * drop a sound entry and fall back to a placeholder that is already known to be refused. + */ +export function noteRefusedBorrowedToolNames( + provider: string, + model: string, + session?: string +): void { + const key = observed.has(keyOf(provider, model, session)) + ? keyOf(provider, model, session) + : keyOf(provider, model); + if (!observed.has(key)) return; + const streak = (consecutiveRefusals.get(key) ?? 0) + 1; + if (streak < REFUSALS_BEFORE_FORGETTING) { + consecutiveRefusals.set(key, streak); + return; + } + observed.delete(key); + consecutiveRefusals.delete(key); +} + +/** The names last seen accepted for this surface, model and — when known — session. */ +export function getObservedToolNames( + provider: string, + model: string, + session?: string +): readonly string[] | null { + return observed.get(keyOf(provider, model, session)) ?? null; +} + +/** + * The names to declare on a gated request the caller sent without tools. + * + * Order of resolution: what this very conversation was last seen declaring, then what any + * conversation on this surface and model was, then what the operator configured, then + * nothing — which leaves the caller on the built-in placeholder. + * + * The first step is the one that matters. The official client sends its service requests — + * title generation, compaction — without tools while the build requests of the same + * session carry a full list (upstream anomalyco/opencode#49433), so replaying that + * session\'s own list gives a request back the contract its own client just declared. + */ +export function resolvePlaceholderNames( + provider: string, + model: string, + session: string | undefined, + configured: readonly string[] +): readonly string[] { + const own = session ? getObservedToolNames(provider, model, session) : null; + return own ?? getObservedToolNames(provider, model) ?? configured; +} + +/** Reserved for tests. */ +export function _resetToolObservationForTests(): void { + observed.clear(); + consecutiveRefusals.clear(); +} diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 398d53c011..3b7b46db78 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -1,7 +1,99 @@ -import { randomUUID } from "crypto"; +import { createHash, randomBytes, randomUUID } from "crypto"; import { setUserAgentHeader } from "../executors/base.ts"; import { generateSessionId } from "../services/sessionManager.ts"; +/** + * Default synthesized User-Agent. The upstream only parses the version, so this literal + * exists to be recent enough, not to impersonate a build: any `opencode/<>=1.17>` passes. + * Overridable through the existing OPENCODE_USER_AGENT (or _USER_AGENT) knob. + */ +export const DEFAULT_OPENCODE_USER_AGENT = "opencode/1.18.31"; + +/** Canonical OpenCode session id shape: `ses_` + 12 hex + 14 base62. */ +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}$/; + +const MINIMUM_USER_AGENT_MINOR = 17; +const USER_AGENT_VERSION_RE = /opencode\/(?:[a-z]+\/)?v?(\d+)\.(\d+)/i; + +/** Whether a User-Agent already satisfies the upstream contract, so it must be kept. */ +export function satisfiesOpencodeUserAgentContract(userAgent: string | null | undefined): boolean { + const match = String(userAgent || "").match(USER_AGENT_VERSION_RE); + if (!match) return false; + const major = Number.parseInt(match[1], 10); + const minor = Number.parseInt(match[2], 10); + if (!Number.isFinite(major) || !Number.isFinite(minor)) return false; + return major > 1 || (major === 1 && minor >= MINIMUM_USER_AGENT_MINOR); +} + +/** + * The session id the caller supplied, if any. + * + * Only a client-supplied value joins two requests of one conversation: a synthesized one + * is derived from the body, and the body of a build request and of the title request that + * follows it differ — including in their tool list, which is the very thing being joined. + */ +export function clientSuppliedOpencodeSession( + clientHeaders: Record | null | undefined +): string | undefined { + if (!clientHeaders) return undefined; + const value = + findHeader(clientHeaders, "x-opencode-session") ?? findHeader(clientHeaders, "x-session-id"); + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * The CLI identity defaults the upstream expects, or `undefined` when synthesis is off. + * + * Lives here rather than in the executor because this module already owns the default + * user-agent and the contract that validates one. `gated` says the free tier will inspect + * this request: outside the gate a configured user-agent is honoured as-is (the #5997 + * contract, which `opencode-go` and paid models rely on), while on a gated request one + * that does not satisfy the version rule is replaced — an operator still carrying the + * previous unversioned default would otherwise be refused. + */ +export function resolveOpencodeCliDefaults( + providerId: string, + gated: boolean +): { userAgent: string; client: string; project: string } | undefined { + if (/^(0|false|no|off)$/i.test(process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "")) { + return undefined; + } + const envUAKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`; + const configuredUA = process.env[envUAKey]?.trim() || process.env.OPENCODE_USER_AGENT?.trim(); + return { + userAgent: + configuredUA && (!gated || satisfiesOpencodeUserAgentContract(configuredUA)) + ? configuredUA + : DEFAULT_OPENCODE_USER_AGENT, + client: process.env.OPENCODE_CLIENT?.trim() || "desktop", + project: process.env.OPENCODE_PROJECT?.trim() || "global", + }; +} + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +function base62From(bytes: Buffer, length: number): string { + return Array.from(bytes.subarray(0, length), (byte) => BASE62[byte % 62]).join(""); +} + +/** + * 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. + */ +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)}`; +} + /** * Header keys that are forwarded from the client to the upstream provider. * Used by both OpencodeExecutor and DefaultExecutor. @@ -53,7 +145,7 @@ function findHeader(headers: Record, name: string): string | und * synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs * from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229) * @param options.sessionBody - Request body fields used to generate a - * conversation-stable session fingerprint (model, system, messages, tools). + * 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. */ @@ -67,6 +159,7 @@ export function forwardOpencodeClientHeaders( model?: string; system?: unknown; messages?: Array<{ role?: string; content?: unknown }>; + input?: Array<{ role?: string; content?: unknown }>; tools?: Array<{ name?: string; function?: { name?: string } }>; }; } @@ -98,6 +191,9 @@ export function forwardOpencodeClientHeaders( const sessionAffinity = findHeader(clientHeaders, "x-session-affinity") || findHeader(clientHeaders, "x-session-id"); if (sessionAffinity) { + // Kept as-is here. When identity synthesis is on, applyCliDefaults renders it in the + // canonical shape below; with the synthesis opted out this path stays byte-identical + // to before, since opting out means no fabricated identity at all. headers["x-opencode-session"] = sessionAffinity; if (!headers["x-opencode-request"]) { @@ -129,18 +225,31 @@ function applyCliDefaults( model?: string; system?: unknown; messages?: Array<{ role?: string; content?: unknown }>; + input?: Array<{ role?: string; content?: unknown }>; tools?: Array<{ name?: string; function?: { name?: string } }>; } ): void { + // A client User-Agent is kept only when it already satisfies the upstream contract. + // The previous rule kept anything starting with `opencode-cli/`, which carries no + // parsable version and is refused by the free tier. const existingUa = headers["User-Agent"] || headers["user-agent"]; - const clientUaIsCliLike = - typeof existingUa === "string" && /^opencode-cli\//i.test(existingUa.trim()); - if (!clientUaIsCliLike) { + if (!satisfiesOpencodeUserAgentContract(existingUa)) { setUserAgentHeader(headers, cliDefaults.userAgent); } headers["x-opencode-client"] ||= cliDefaults.client; headers["x-opencode-project"] ||= cliDefaults.project; - headers["x-opencode-request"] ||= randomUUID(); - headers["x-opencode-session"] ||= - generateSessionId(sessionBody ?? null) || randomUUID(); + // Both ids go out in the canonical shape. A client value already in that shape is kept; + // anything else (a UUID from a generic client, an opaque conversation key) is translated + // deterministically, so one client conversation still maps to one upstream session. + const clientRequestId = headers["x-opencode-request"]?.trim(); + headers["x-opencode-request"] = + clientRequestId && OPENCODE_REQUEST_PATTERN.test(clientRequestId) + ? clientRequestId + : canonicalId("msg_", clientRequestId || undefined); + const clientSessionId = headers["x-opencode-session"]?.trim(); + headers["x-opencode-session"] = clientSessionId + ? OPENCODE_SESSION_PATTERN.test(clientSessionId) + ? clientSessionId + : canonicalId("ses_", clientSessionId) + : canonicalId("ses_", generateSessionId(sessionBody ?? null) ?? undefined); } diff --git a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts index 02ed7df03e..e72ef4c37f 100644 --- a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts +++ b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts @@ -30,16 +30,21 @@ import assert from "node:assert/strict"; import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeaders.ts"; import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Since 2026-09-17 the free tier requires the canonical OpenCode id shapes; a UUID is +// refused with a 403. The synthesized ids therefore match these patterns, not a UUID. +const SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; +const REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; // Values passed explicitly to forwardOpencodeClientHeaders()'s `cliDefaults` option in // the tests below — these are caller-supplied, independent of OpencodeExecutor's own // env-driven defaults (covered separately by the OPENCODE_DEFAULTS constant + the // OpencodeExecutor.buildHeaders tests further down). -const CLI_DEFAULTS = { userAgent: "opencode-cli/1.0.0", client: "cli", project: "default" }; +const CLI_DEFAULTS = { userAgent: "opencode/1.18.31", client: "cli", project: "default" }; // PR #10571's new synthesized defaults for OpencodeExecutor.buildHeaders() itself. -const OPENCODE_DEFAULTS = { userAgent: "opencode", client: "desktop", project: "global" }; +// The user-agent default carries a version since 2026-09-17: the free tier refuses a +// bare `opencode` and answers 426 below version 1.17. +const OPENCODE_DEFAULTS = { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }; function withEnv(key: string, value: string | undefined, fn: () => void) { const saved = process.env[key]; @@ -57,15 +62,15 @@ test("forwardOpencodeClientHeaders: cliDefaults synthesize all CLI identity head const headers: Record = {}; forwardOpencodeClientHeaders(headers, {}, { cliDefaults: CLI_DEFAULTS }); - assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); + assert.equal(headers["User-Agent"], CLI_DEFAULTS.userAgent); assert.equal(headers["x-opencode-client"], "cli"); assert.equal(headers["x-opencode-project"], "default"); - assert.match(headers["x-opencode-request"] ?? "", UUID_RE); - assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + assert.match(headers["x-opencode-request"] ?? "", REQUEST_RE); + assert.match(headers["x-opencode-session"] ?? "", SESSION_RE); assert.notEqual(headers["x-opencode-request"], headers["x-opencode-session"]); }); -test("forwardOpencodeClientHeaders: non-CLI client UA is REPLACED with the CLI UA; other headers keep client-wins [#5997 follow-up]", () => { +test("forwardOpencodeClientHeaders: non-CLI client UA is REPLACED with the CLI UA; client-wins survives as a deterministic mapping for the ids [#5997 follow-up]", () => { const headers: Record = {}; const clientHeaders = { "User-Agent": "curl/8.5.0", @@ -76,18 +81,41 @@ test("forwardOpencodeClientHeaders: non-CLI client UA is REPLACED with the CLI U }; forwardOpencodeClientHeaders(headers, clientHeaders, { cliDefaults: CLI_DEFAULTS }); - assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); + assert.equal(headers["User-Agent"], CLI_DEFAULTS.userAgent); 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"); + // The client's own ids cannot go out as they are (the upstream refuses any other shape), + // so they are translated deterministically: the same client value always maps to the + // same upstream id, which is what session continuity and prompt caching need. + assert.match(headers["x-opencode-request"] ?? "", REQUEST_RE); + assert.match(headers["x-opencode-session"] ?? "", SESSION_RE); + const again: Record = {}; + forwardOpencodeClientHeaders(again, clientHeaders, { cliDefaults: CLI_DEFAULTS }); + assert.equal(again["x-opencode-session"], headers["x-opencode-session"]); }); -test("forwardOpencodeClientHeaders: an existing opencode-cli UA is preserved (real CLI version intact)", () => { +test("forwardOpencodeClientHeaders: a client UA satisfying the upstream contract is preserved", () => { 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"); + forwardOpencodeClientHeaders( + headers, + { "User-Agent": "opencode/2.5.0" }, + { + cliDefaults: CLI_DEFAULTS, + } + ); + assert.equal(headers["User-Agent"], "opencode/2.5.0", "a real client version stays intact"); +}); + +test("forwardOpencodeClientHeaders: an unversioned opencode-cli UA is replaced (the upstream refuses it)", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "User-Agent": "opencode-cli/2.5.0" }, + { + cliDefaults: CLI_DEFAULTS, + } + ); + assert.equal(headers["User-Agent"], CLI_DEFAULTS.userAgent); }); test("forwardOpencodeClientHeaders: without cliDefaults, no synthesis (DefaultExecutor path unchanged)", () => { @@ -105,7 +133,7 @@ test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults by default — fla assert.equal(headers["User-Agent"], OPENCODE_DEFAULTS.userAgent); assert.equal(headers["x-opencode-client"], OPENCODE_DEFAULTS.client); assert.equal(headers["x-opencode-project"], OPENCODE_DEFAULTS.project); - assert.match(headers["x-opencode-request"] ?? "", UUID_RE); + assert.match(headers["x-opencode-request"] ?? "", REQUEST_RE); }); }); @@ -117,8 +145,8 @@ test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults with flag explicit assert.equal(headers["User-Agent"], OPENCODE_DEFAULTS.userAgent); assert.equal(headers["x-opencode-client"], OPENCODE_DEFAULTS.client); assert.equal(headers["x-opencode-project"], OPENCODE_DEFAULTS.project); - assert.match(headers["x-opencode-request"] ?? "", UUID_RE); - assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + assert.match(headers["x-opencode-request"] ?? "", REQUEST_RE); + assert.match(headers["x-opencode-session"] ?? "", SESSION_RE); }); }); diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index ef190193e3..f1f8d84063 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -236,7 +236,10 @@ describe("OpencodeExecutor", () => { }); it("omits accept header when stream is false", async () => { - const result = await zenExecutor.execute(createInput("big-pickle", false)); + // A paid model carries the client's non-streaming expectation through. A free-tier + // model does not: the gated tier only answers streamed requests, so the executor + // announces the event stream and the JSON body is rebuilt from it. + const result = await zenExecutor.execute(createInput("gpt-5.6-luna", false)); assert.deepEqual(result.headers, { Authorization: "Bearer test-key", @@ -245,6 +248,12 @@ describe("OpencodeExecutor", () => { assert.deepEqual(fetchCalls[0].options.headers, result.headers); }); + it("announces the event stream for a free-tier model even when the client wants JSON", async () => { + const result = await zenExecutor.execute(createInput("big-pickle", false)); + + assert.equal(result.headers["Accept"], "text/event-stream"); + }); + it("omits authorization when credentials are missing", async () => { const result = await zenExecutor.execute(createInput("minimax-m2.5-free", true, null)); diff --git a/tests/unit/opencode-free-tier-request-contract.test.ts b/tests/unit/opencode-free-tier-request-contract.test.ts new file mode 100644 index 0000000000..87d6d9f9dc --- /dev/null +++ b/tests/unit/opencode-free-tier-request-contract.test.ts @@ -0,0 +1,752 @@ +/** + * OpenCode Zen free-tier request contract. + * + * Since 2026-09-17 the free tier answers 403 FreeTierError unless the request carries + * all four of: `stream: true`, a non-empty `tools` array, an `x-opencode-session` (or + * `x-session-id`) shaped `ses_` + 12 hex + 14 base62, and a `User-Agent` containing + * `opencode/= 1.17>`. Removing any one of the four turns a 200 into a 403, and + * a version below 1.17 answers 426 instead. + * + * Measured again on 2026-09-18, and the tools condition is narrower than it first looked: + * the upstream inspects which names are declared. One made-up name, twelve made-up names + * and twelve plausible names borrowed from another vocabulary are all refused, while six, + * nine and twelve names from the official client pass; three of those same names are + * refused. What the upstream takes also differs by model and moves between days, which is + * why the placeholder is resolved at runtime rather than pinned here. + * + * `tool_choice` is never imposed on any surface: the upstream rejects every value but + * "auto" with a 400 invalid_request_error (`only "auto" is supported for tool_choice`). + * + * Paid models on the same host are not gated (one without tools answers 401), so the + * request contract applies to free-tier models only. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + applyFreeTierRequestContract, + configuredPlaceholderToolNames, + noteFreeTierOutcome, + prepareFreeTierRequest, + isGatedFreeTierRequest, + requiresFreeTierRequestContract, + surfaceFromBaseUrl, +} from "../../open-sse/executors/opencodeFreeTierContract.ts"; +import { + _resetToolObservationForTests, + getObservedToolNames, +} from "../../open-sse/executors/opencodeToolObservation.ts"; +import { + DEFAULT_OPENCODE_USER_AGENT, + OPENCODE_SESSION_PATTERN, + clientSuppliedOpencodeSession, + forwardOpencodeClientHeaders, + satisfiesOpencodeUserAgentContract, +} from "../../open-sse/utils/opencodeHeaders.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; + +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" }], +}); + +test("the contract applies to free-tier models on the gated surface only", () => { + assert.equal( + requiresFreeTierRequestContract("zen", "opencode", "nemotron-3.5-lightning-free"), + true + ); + assert.equal(requiresFreeTierRequestContract("zen", "opencode-zen", "big-pickle"), true); + assert.equal( + requiresFreeTierRequestContract("zen", "opencode-zen", "muse-spark-1.3-contributor-free"), + true + ); + assert.equal(requiresFreeTierRequestContract("zen", "opencode-zen", "gpt-5.6-luna"), false); + // The other surface refuses a request that carries tools rather than one that does not + // (upstream anomalyco/opencode#44300 and #44382), so it is never brought up to this + // contract — including for the free model its registry declares. + assert.equal(requiresFreeTierRequestContract("go", "opencode-go", "big-pickle"), false); + assert.equal(requiresFreeTierRequestContract("go", "opencode-go", "ox-alpha-free"), false); + assert.equal(requiresFreeTierRequestContract("other", "groq", "big-pickle"), false); +}); + +test("the surface is told apart by base url, so the `oc` alias needs no special case", () => { + assert.equal(surfaceFromBaseUrl("https://opencode.ai/zen/v1"), "zen"); + assert.equal(surfaceFromBaseUrl("https://opencode.ai/zen/go/v1"), "go"); + assert.equal(surfaceFromBaseUrl("https://api.groq.com/openai/v1"), "other"); + assert.equal(surfaceFromBaseUrl(undefined), "other"); +}); + +test("chat completions: the contract adds streaming and a placeholder tool, 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[0].type, "function"); + assert.equal(tools[0].function.name, "_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); +}); + +test("the placeholder carries the names it is given, one entry each", () => { + const body = applyFreeTierRequestContract(CHAT_BODY(), "openai", [ + "glob", + "grep", + "read", + ]) as Record; + const tools = body.tools as Array<{ function: { name: string; parameters: object } }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["glob", "grep", "read"] + ); + // 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: {} }); +}); + +test("responses: several placeholder names keep the flat shape and no tool_choice", () => { + const body = applyFreeTierRequestContract(RESPONSES_BODY(), "openai-responses", [ + "glob", + "grep", + ]) as Record; + const tools = body.tools as Array<{ type: string; name: string }>; + assert.deepEqual( + tools.map((t) => t.name), + ["glob", "grep"] + ); + assert.equal("tool_choice" in body, false); +}); + +test("an empty tools array counts as no tools", () => { + // It is the exact shape the upstream refuses — the official client sends it on its own + // compaction path (upstream anomalyco/opencode#49433). + const body = applyFreeTierRequestContract({ ...CHAT_BODY(), tools: [] }, "openai") as Record< + string, + unknown + >; + assert.equal((body.tools as unknown[]).length, 1); +}); + +test("responses: the placeholder tool 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< + string, + unknown + >; + assert.equal(body.stream, true); + const tools = body.tools as Array<{ type: string; name: string }>; + assert.equal(tools.length, 1); + assert.equal(tools[0].type, "function"); + assert.equal(tools[0].name, "_noop"); + assert.equal("tool_choice" in body, false); +}); + +test("client-supplied tools are never replaced, and no tool_choice is imposed", () => { + const clientTools = [ + { type: "function", function: { name: "search", parameters: { type: "object" } } }, + ]; + const body = applyFreeTierRequestContract( + { ...CHAT_BODY(), tools: clientTools }, + "openai" + ) as Record; + assert.deepEqual(body.tools, clientTools); + assert.equal("tool_choice" in body, false); + assert.equal(body.stream, true); +}); + +test("a client tool_choice is preserved", () => { + const body = applyFreeTierRequestContract( + { ...CHAT_BODY(), tool_choice: "auto" }, + "openai" + ) as Record; + assert.equal(body.tool_choice, "auto"); +}); + +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); +}); + +test("an unknown body format only gets the streaming flag", () => { + const body = applyFreeTierRequestContract(CHAT_BODY(), "claude") as Record; + assert.equal(body.stream, true); + assert.equal("tools" in body, false); + assert.equal("tool_choice" in body, false); +}); + +test("the user-agent contract accepts a versioned opencode client and rejects the rest", () => { + assert.equal(satisfiesOpencodeUserAgentContract(DEFAULT_OPENCODE_USER_AGENT), true); + assert.equal(satisfiesOpencodeUserAgentContract("opencode/1.17.0"), true); + assert.equal(satisfiesOpencodeUserAgentContract("opencode/beta/1.18.31/cli"), true); + assert.equal( + satisfiesOpencodeUserAgentContract("opencode/1.18.14 ai-sdk/provider-utils/4.0.23"), + true + ); + // Measured: 1.16.0 answers 426 UpgradeRequired, so it does not satisfy the contract. + assert.equal(satisfiesOpencodeUserAgentContract("opencode/1.16.0"), false); + assert.equal(satisfiesOpencodeUserAgentContract("opencode-cli/1.0.0"), false); + assert.equal(satisfiesOpencodeUserAgentContract("opencode"), false); + assert.equal(satisfiesOpencodeUserAgentContract("curl/8.5.0"), false); + assert.equal(satisfiesOpencodeUserAgentContract(undefined), false); +}); + +test("synthesized identity headers carry a canonical session and request id", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + {}, + { + cliDefaults: { + userAgent: DEFAULT_OPENCODE_USER_AGENT, + client: "desktop", + project: "global", + }, + sessionBody: CHAT_BODY(), + } + ); + assert.equal(headers["User-Agent"], DEFAULT_OPENCODE_USER_AGENT); + assert.match(headers["x-opencode-session"] ?? "", OPENCODE_SESSION_PATTERN); + assert.match(headers["x-opencode-request"] ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); +}); + +test("the same conversation keeps the same session id, a different one does not", () => { + const sessionFor = (body: Record) => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + {}, + { + cliDefaults: { + userAgent: DEFAULT_OPENCODE_USER_AGENT, + client: "desktop", + project: "global", + }, + sessionBody: body, + } + ); + return headers["x-opencode-session"]; + }; + const first = sessionFor(CHAT_BODY()); + assert.equal(sessionFor(CHAT_BODY()), first, "conversation-stable, so upstream caching hits"); + assert.notEqual( + sessionFor({ ...CHAT_BODY(), messages: [{ role: "user", content: "other" }] }), + first + ); +}); + +test("a client session already in the canonical shape is preserved", () => { + const canonical = "ses_0123456789abABCDEFGHIJKLMN"; + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": canonical }, + { + cliDefaults: { + userAgent: DEFAULT_OPENCODE_USER_AGENT, + client: "desktop", + project: "global", + }, + } + ); + assert.equal(headers["x-opencode-session"], canonical); +}); + +test("a foreign client session is translated into the canonical shape, deterministically", () => { + const translate = () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": "conv-42" }, + { + cliDefaults: { + userAgent: DEFAULT_OPENCODE_USER_AGENT, + client: "desktop", + project: "global", + }, + } + ); + return headers["x-opencode-session"]; + }; + const translated = translate(); + assert.match(translated ?? "", OPENCODE_SESSION_PATTERN); + assert.equal(translate(), translated, "same client session, same upstream session"); +}); + +test("buildHeaders: a free-tier request accepts an event stream even when the client wants JSON", () => { + const executor = new OpencodeExecutor("opencode-zen"); + const headers = executor.buildHeaders(null, false, null, "nemotron-3.5-lightning-free"); + assert.equal(headers["Accept"], "text/event-stream"); + assert.equal(headers["User-Agent"], DEFAULT_OPENCODE_USER_AGENT); + assert.match(headers["x-opencode-session"] ?? "", OPENCODE_SESSION_PATTERN); +}); + +test("buildHeaders: a paid model keeps the client's non-streaming expectation", () => { + const executor = new OpencodeExecutor("opencode-zen"); + const headers = executor.buildHeaders(null, false, null, "gpt-5.6-luna"); + assert.equal(headers["Accept"], undefined); +}); + +test("transformRequest: the contract is applied for a free model and skipped for a paid one", () => { + const executor = new OpencodeExecutor("opencode-zen"); + const free = executor.transformRequest( + "nemotron-3.5-lightning-free", + CHAT_BODY(), + false, + null as never + ) as Record; + assert.equal(free.stream, true); + assert.equal((free.tools as unknown[]).length, 1); + + const paid = executor.transformRequest( + "gpt-5.6-luna", + { model: "gpt-5.6-luna", messages: [{ role: "user", content: "hi" }] }, + false, + null as never + ) as Record; + assert.equal("tools" in paid, false); + assert.equal(paid.stream, undefined); +}); + +test("a JSON caller gets a JSON body back even though the upstream request was streamed", async () => { + // The contract forces streaming, so the executor owes every caller that asked for JSON a + // JSON body — handleChatCore can buffer an event stream, but the other consumers + // (compression judge, token counting) call execute() and read response.json() directly. + const executor = new OpencodeExecutor("opencode-zen"); + const originalFetch = globalThis.fetch; + const seen: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen.push(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: "nemotron-3.5-lightning-free", + body: { model: "nemotron-3.5-lightning-free", messages: [{ role: "user", content: "hi" }] }, + stream: false, + signal: null, + credentials: { apiKey: "k", accessToken: null, connectionId: "c" }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + })) 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.match(result.response.headers.get("content-type") ?? "", /application\/json/); + const json = (await result.response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + assert.equal(json.choices?.[0]?.message?.content, "hi"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a streaming caller still receives the event stream untouched", async () => { + const executor = new OpencodeExecutor("opencode-zen"); + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + })) as typeof globalThis.fetch; + try { + const result = (await executor.execute({ + model: "nemotron-3.5-lightning-free", + body: { + model: "nemotron-3.5-lightning-free", + messages: [{ role: "user", content: "hi" }], + stream: true, + }, + stream: true, + signal: null, + credentials: { apiKey: "k", accessToken: null, connectionId: "c" }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + })) as { response: Response }; + assert.match(result.response.headers.get("content-type") ?? "", /text\/event-stream/); + await result.response.body?.cancel(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a gated request repairs a stale configured user-agent, a non-gated one keeps it", () => { + // An operator still carrying the previous default in OPENCODE_USER_AGENT would otherwise + // keep sending `opencode`, which the free tier refuses. Outside the gate the configured + // value is still honoured verbatim (#5997). + const saved = process.env.OPENCODE_USER_AGENT; + process.env.OPENCODE_USER_AGENT = "opencode"; + try { + const executor = new OpencodeExecutor("opencode-zen"); + const gated = executor.buildHeaders(null, true, null, "nemotron-3.5-lightning-free"); + assert.equal(gated["User-Agent"], DEFAULT_OPENCODE_USER_AGENT); + assert.ok(satisfiesOpencodeUserAgentContract(gated["User-Agent"])); + + const paid = executor.buildHeaders(null, true, null, "gpt-5.6-luna"); + assert.equal(paid["User-Agent"], "opencode"); + } finally { + if (saved === undefined) delete process.env.OPENCODE_USER_AGENT; + else process.env.OPENCODE_USER_AGENT = saved; + } +}); + +test("responses: two conversations on the same model get different session ids", () => { + const executor = new OpencodeExecutor("opencode"); + executor._requestFormat = "openai-responses"; + const sessionFor = (text: string) => + executor.buildHeaders(null, true, null, "muse-spark-1.3-contributor-free", undefined, { + model: "muse-spark-1.3-contributor-free", + input: [{ role: "user", content: text }], + })["x-opencode-session"]; + + const first = sessionFor("first conversation"); + assert.match(first ?? "", OPENCODE_SESSION_PATTERN); + assert.equal(sessionFor("first conversation"), first, "same conversation, same session"); + assert.notEqual( + sessionFor("a completely different opening message"), + first, + "the Responses surface carries the conversation under input, not messages" + ); +}); + +// ── The placeholder is resolved from what the upstream is currently accepting ────────── + +test("the opt-out switches the body half off and leaves the scope predicate alone", () => { + const previous = process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT; + try { + process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT = "off"; + assert.equal( + requiresFreeTierRequestContract("zen", "opencode", "nemotron-3.5-lightning-free"), + false + ); + assert.equal(isGatedFreeTierRequest("zen", "opencode", "nemotron-3.5-lightning-free"), true); + const { body, attempt } = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + assert.equal(attempt, null); + assert.equal("tools" in (body as Record), false); + assert.equal("stream" in (body as Record), false); + } finally { + if (previous === undefined) delete process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT; + else process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT = previous; + } +}); + +test("configured placeholder names are parsed, bad entries dropped, unset stays empty", () => { + const previous = process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS; + try { + delete process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS; + assert.deepEqual(configuredPlaceholderToolNames(), []); + process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS = " glob , grep ,,9bad, glob ,ok-name"; + assert.deepEqual(configuredPlaceholderToolNames(), ["glob", "grep", "ok-name"]); + } finally { + if (previous === undefined) delete process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS; + else process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS = previous; + } +}); + +test("with nothing observed and nothing configured, the built-in placeholder is used", () => { + _resetToolObservationForTests(); + const { body, attempt } = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + const tools = (body as Record).tools as Array<{ function: { name: string } }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["_noop"] + ); + // Nothing was borrowed, so a refusal here must not be charged against the store. + assert.equal(attempt?.borrowed, false); +}); + +test("an accepted request teaches the names it carried, and a later bare request borrows them", () => { + _resetToolObservationForTests(); + const withTools = { + ...CHAT_BODY(), + tools: [ + { type: "function", function: { name: "glob" } }, + { type: "function", function: { name: "grep" } }, + ], + }; + const first = prepareFreeTierRequest( + withTools, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + assert.deepEqual(first.attempt?.clientToolNames, ["glob", "grep"]); + noteFreeTierOutcome(first.attempt, true); + assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), [ + "glob", + "grep", + ]); + + const second = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + const tools = (second.body as Record).tools as Array<{ + function: { name: string }; + }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["glob", "grep"] + ); + assert.equal(second.attempt?.borrowed, true); +}); + +test("what one model learns stays with that model", () => { + // Asserts an absence, so it stays green if the store is removed entirely — it guards + // against cross-model leakage, not against the mechanism disappearing. + _resetToolObservationForTests(); + const learned = prepareFreeTierRequest( + { ...CHAT_BODY(), tools: [{ type: "function", function: { name: "glob" } }] }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + noteFreeTierOutcome(learned.attempt, true); + assert.equal(getObservedToolNames("opencode", "big-pickle"), null); + assert.equal(getObservedToolNames("opencode-zen", "nemotron-3.5-lightning-free"), null); +}); + +test("a borrowed set is dropped after three refusals in a row, not after one", () => { + _resetToolObservationForTests(); + const learned = prepareFreeTierRequest( + { ...CHAT_BODY(), tools: [{ type: "function", function: { name: "glob" } }] }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + noteFreeTierOutcome(learned.attempt, true); + + const borrow = () => + prepareFreeTierRequest(CHAT_BODY(), "openai", "zen", "opencode", "nemotron-3.5-lightning-free") + .attempt; + // One refusal is not proof: the same body was refused and then accepted minutes apart on + // a free model (measured 2026-09-18), so a single verdict must not clear the entry. + noteFreeTierOutcome(borrow(), false); + assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), ["glob"]); + noteFreeTierOutcome(borrow(), false); + assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), ["glob"]); + noteFreeTierOutcome(borrow(), false); + assert.equal(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), null); +}); + +test("an acceptance resets the refusal streak", () => { + _resetToolObservationForTests(); + const withTools = { + ...CHAT_BODY(), + tools: [{ type: "function", function: { name: "glob" } }], + }; + const learned = prepareFreeTierRequest( + withTools, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + noteFreeTierOutcome(learned.attempt, true); + const borrow = () => + prepareFreeTierRequest(CHAT_BODY(), "openai", "zen", "opencode", "nemotron-3.5-lightning-free") + .attempt; + noteFreeTierOutcome(borrow(), false); + noteFreeTierOutcome(borrow(), false); + noteFreeTierOutcome( + prepareFreeTierRequest(withTools, "openai", "zen", "opencode", "nemotron-3.5-lightning-free") + .attempt, + true + ); + noteFreeTierOutcome(borrow(), false); + assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), ["glob"]); +}); + +test("a refusal on the caller's own tools never touches the store", () => { + _resetToolObservationForTests(); + const withTools = { + ...CHAT_BODY(), + tools: [{ type: "function", function: { name: "glob" } }], + }; + noteFreeTierOutcome( + prepareFreeTierRequest(withTools, "openai", "zen", "opencode", "nemotron-3.5-lightning-free") + .attempt, + true + ); + for (let i = 0; i < 5; i++) { + const own = prepareFreeTierRequest( + withTools, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free" + ); + assert.equal(own.attempt?.borrowed, false); + noteFreeTierOutcome(own.attempt, false); + } + assert.deepEqual(getObservedToolNames("opencode", "nemotron-3.5-lightning-free"), ["glob"]); +}); + +test("the other surface is left alone entirely", () => { + // Scope guard only. That `/zen/go/v1` refuses a request carrying tools comes from + // upstream anomalyco/opencode#44300 and #44382, not from a measurement here: this + // installation exposes no model on that surface. + _resetToolObservationForTests(); + const { body, attempt } = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "go", + "opencode-go", + "ox-alpha-free" + ); + assert.equal(attempt, null); + assert.equal("tools" in (body as Record), false); + assert.equal("stream" in (body as Record), false); +}); + +// ── A conversation repairs its own service requests ─────────────────────────────────── + +const SES_A = "ses_0123456789abABCDEFGHIJKLMN"; +const SES_B = "ses_fedcba987654ZYXWVUTSRQPONM"; + +test("a session's service request borrows the list its own build request declared", () => { + _resetToolObservationForTests(); + // The official client sends its build requests with a full tool list and its title and + // compaction requests with none, on the same session (upstream + // anomalyco/opencode#49433). The second gets back what the first declared. + const build = prepareFreeTierRequest( + { + ...CHAT_BODY(), + tools: [ + { type: "function", function: { name: "glob" } }, + { type: "function", function: { name: "grep" } }, + ], + }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_A + ); + noteFreeTierOutcome(build.attempt, true); + + const title = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_A + ); + const tools = (title.body as Record).tools as Array<{ + function: { name: string }; + }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["glob", "grep"] + ); + assert.equal(title.attempt?.borrowed, true); +}); + +test("a session's own list wins over the shared one", () => { + _resetToolObservationForTests(); + // Order matters: the session entry is recorded FIRST and the shared entry overwritten + // afterwards by another conversation, so a store keyed by model alone would answer + // "shared" here and this case would fail. + const own = prepareFreeTierRequest( + { ...CHAT_BODY(), tools: [{ type: "function", function: { name: "own" } }] }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_A + ); + noteFreeTierOutcome(own.attempt, true); + const shared = prepareFreeTierRequest( + { ...CHAT_BODY(), tools: [{ type: "function", function: { name: "shared" } }] }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_B + ); + noteFreeTierOutcome(shared.attempt, true); + + const borrowed = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_A + ); + const tools = (borrowed.body as Record).tools as Array<{ + function: { name: string }; + }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["own"] + ); +}); + +test("a session that has declared nothing yet falls back to the shared entry", () => { + _resetToolObservationForTests(); + noteFreeTierOutcome( + prepareFreeTierRequest( + { ...CHAT_BODY(), tools: [{ type: "function", function: { name: "shared" } }] }, + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_A + ).attempt, + true + ); + const fresh = prepareFreeTierRequest( + CHAT_BODY(), + "openai", + "zen", + "opencode", + "nemotron-3.5-lightning-free", + SES_B + ); + const tools = (fresh.body as Record).tools as Array<{ + function: { name: string }; + }>; + assert.deepEqual( + tools.map((t) => t.function.name), + ["shared"] + ); +}); + +test("a client session id is read case-insensitively, a synthesized one is not borrowed from", () => { + assert.equal(clientSuppliedOpencodeSession({ "X-OpenCode-Session": SES_A }), SES_A); + assert.equal(clientSuppliedOpencodeSession({ "x-session-id": SES_B }), SES_B); + assert.equal(clientSuppliedOpencodeSession({ "x-opencode-session": " " }), undefined); + assert.equal(clientSuppliedOpencodeSession({}), undefined); + assert.equal(clientSuppliedOpencodeSession(undefined), undefined); +}); diff --git a/tests/unit/opencode-session-fingerprint-headers-10571.test.ts b/tests/unit/opencode-session-fingerprint-headers-10571.test.ts index f05da45300..0c70b1800d 100644 --- a/tests/unit/opencode-session-fingerprint-headers-10571.test.ts +++ b/tests/unit/opencode-session-fingerprint-headers-10571.test.ts @@ -4,11 +4,15 @@ * fresh random UUID on every request, so upstream prompt caching can hit * across requests belonging to the same conversation. * - * `open-sse/utils/opencodeHeaders.ts::applyCliDefaults` now derives + * `open-sse/utils/opencodeHeaders.ts::applyCliDefaults` derives * `x-opencode-session` from `generateSessionId(sessionBody)` - * (`open-sse/services/sessionManager.ts`) when a `sessionBody` is supplied, - * falling back to `randomUUID()` only when no fingerprint can be derived - * (e.g. an empty/missing body). + * (`open-sse/services/sessionManager.ts`) when a `sessionBody` is supplied. + * + * Since 2026-09-17 the upstream also checks the SHAPE of that header: `ses_` + 12 hex + + * 14 base62, anything else answering 403 FreeTierError. The fingerprint therefore goes + * out rendered in that shape — stability (and so prompt caching) is unchanged, only the + * rendering is — and a request without a derivable fingerprint gets a random id in the + * same shape rather than a UUID. */ import { test } from "node:test"; import assert from "node:assert/strict"; @@ -16,9 +20,9 @@ import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeade import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const SESSION_HASH_RE = /^[0-9a-f]{16}$/i; +const SESSION_HASH_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; -const CLI_DEFAULTS = { userAgent: "opencode", client: "desktop", project: "global" }; +const CLI_DEFAULTS = { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }; const CONVERSATION_A = { model: "big-pickle", @@ -125,20 +129,27 @@ test("x-opencode-session CHANGES when the first user message (conversation ident ); }); -test("x-opencode-session falls back to a random UUID when no sessionBody is provided", () => { +test("x-opencode-session falls back to a random canonical id when no sessionBody is provided", () => { const headers: Record = {}; forwardOpencodeClientHeaders(headers, {}, { cliDefaults: CLI_DEFAULTS }); - assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + assert.match(headers["x-opencode-session"] ?? "", SESSION_HASH_RE); + assert.doesNotMatch(headers["x-opencode-session"] ?? "", UUID_RE); }); -test("client-supplied x-opencode-session always wins over the derived fingerprint", () => { - const headers: Record = {}; - forwardOpencodeClientHeaders( - headers, - { "x-opencode-session": "client-supplied-session-id" }, - { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } - ); - assert.equal(headers["x-opencode-session"], "client-supplied-session-id"); +test("a client-supplied x-opencode-session still wins over the derived fingerprint, mapped one-to-one", () => { + const sessionFor = (clientSession: string) => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": clientSession }, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + return headers["x-opencode-session"] ?? ""; + }; + const mapped = sessionFor("client-supplied-session-id"); + assert.match(mapped, SESSION_HASH_RE, "rendered in the shape the upstream accepts"); + assert.equal(sessionFor("client-supplied-session-id"), mapped, "same client session, same id"); + assert.notEqual(sessionFor("another-session-id"), mapped, "different session, different id"); }); test("OpencodeExecutor.buildHeaders derives a stable x-opencode-session from the request body across calls with the same conversation", () => { @@ -160,7 +171,7 @@ test("OpencodeExecutor.buildHeaders derives a stable x-opencode-session from the assert.equal(headersFirst["x-opencode-session"], headersSecond["x-opencode-session"]); }); -test("Responses requests use a UUID x-opencode-session for Muse compatibility", () => { +test("Responses requests use the same canonical session as every other surface", () => { const executor = new OpencodeExecutor("opencode"); executor._requestFormat = "openai-responses"; const headers = executor.buildHeaders( @@ -174,11 +185,10 @@ test("Responses requests use a UUID x-opencode-session for Muse compatibility", input: [], } ); - assert.match( - headers["x-opencode-session"] ?? "", - UUID_RE, - "Responses transport must use a UUID session" - ); + // The UUID this path used to force is now refused upstream. Measured 2026-09-17 on + // muse-spark-1.3-contributor-free: the Responses surface answers 200 with a canonical + // `ses_` session, so the transport no longer needs a shape of its own. + assert.match(headers["x-opencode-session"] ?? "", SESSION_HASH_RE); }); test("OpencodeExecutor.buildHeaders derives a DIFFERENT x-opencode-session for a different conversation body", () => { diff --git a/tests/unit/refactor-buildHeaders-opencode.test.ts b/tests/unit/refactor-buildHeaders-opencode.test.ts index 6c16d9d763..76f7d6ad09 100644 --- a/tests/unit/refactor-buildHeaders-opencode.test.ts +++ b/tests/unit/refactor-buildHeaders-opencode.test.ts @@ -92,16 +92,20 @@ test("OpencodeExecutor.buildHeaders: omits User-Agent when no client UA and synt } }); -test("OpencodeExecutor.buildHeaders: preserves an opencode-cli-like client User-Agent when provided", () => { - // Since #10571 flips CLI-header synthesis to on-by-default, a non-CLI-looking client UA - // (e.g. "opencode/1.17.12") is now REPLACED by the synthesized default (see the - // #5997/#10571 non-CLI-UA-replaced test in opencode-cli-headers-synthesis-5997.test.ts). - // Only a UA that already looks like the real OpenCode CLI ("opencode-cli/…") is preserved. +test("OpencodeExecutor.buildHeaders: preserves a client User-Agent that satisfies the upstream contract", () => { + // The rule is now the upstream one: a UA carrying `opencode/= 1.17>` is kept, + // anything else is replaced by the synthesized default. `opencode-cli/…` carries no + // parsable version, and the free tier refuses it, so it is no longer preserved. const executor = new OpencodeExecutor("opencode"); - const headers = executor.buildHeaders({ apiKey: "key-1" }, true, { + const kept = executor.buildHeaders({ apiKey: "key-1" }, true, { + "User-Agent": "opencode/1.17.12", + }); + assert.equal(kept["User-Agent"], "opencode/1.17.12"); + + const replaced = executor.buildHeaders({ apiKey: "key-1" }, true, { "User-Agent": "opencode-cli/1.17.12", }); - assert.equal(headers["User-Agent"], "opencode-cli/1.17.12"); + assert.notEqual(replaced["User-Agent"], "opencode-cli/1.17.12"); }); test("OpencodeExecutor.buildHeaders: omits x-opencode-client when absent and synthesis is explicitly off", () => {