From 73d4ab3b6bbb6e603a18c4cdcd607682aa25d431 Mon Sep 17 00:00:00 2001 From: b3nw <189466+b3nw@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:00:38 +0000 Subject: [PATCH] fix(opencode): declare free-tier lowercase quartet tool fingerprint and canonical session identity Match upstream OpenCode free-tier request contract and client fingerprint requirements to resolve HTTP 403 FreeTierError ("OpenCode's free tier can only be used from within OpenCode"). - Port upstream diegosouzapw/OmniRoute#15322 and #14013: - Add open-sse/utils/opencodeFingerprint.ts: inject canonical lowercase file-search quartet (`bash`, `glob`, `grep`, `read`) into body.tools, canonicalize client casing on dispatch, and restore caller's tool spelling in SSE/JSON responses. - Add open-sse/executors/opencodeFreeTierContract.ts: gate free models on zen surface, enforce streaming, and lazily rebuild JSON for non-streaming callers. - Update open-sse/utils/opencodeHeaders.ts: default User-Agent to opencode/1.18.31 (>= 1.17) and synthesize canonical `ses_` and `msg_` IDs. - Update OpencodeExecutor to apply free tier contract and restore names. - Add unit tests in tests/unit/opencode-free-tier-fingerprint.test.ts (31 passing). --- .env.example | 2 + docs/reference/ENVIRONMENT.md | 2 + open-sse/executors/opencode.ts | 81 +++- .../executors/opencodeFreeTierContract.ts | 285 ++++++++++++ open-sse/utils/opencodeFingerprint.ts | 431 ++++++++++++++++++ open-sse/utils/opencodeHeaders.ts | 65 ++- ...pencode-cli-headers-synthesis-5997.test.ts | 62 ++- .../opencode-free-tier-fingerprint.test.ts | 403 ++++++++++++++++ ...-session-fingerprint-headers-10571.test.ts | 54 ++- .../refactor-buildHeaders-opencode.test.ts | 18 +- 10 files changed, 1329 insertions(+), 74 deletions(-) create mode 100644 open-sse/executors/opencodeFreeTierContract.ts create mode 100644 open-sse/utils/opencodeFingerprint.ts create mode 100644 tests/unit/opencode-free-tier-fingerprint.test.ts diff --git a/.env.example b/.env.example index 47e1b099..bce75ce6 100644 --- a/.env.example +++ b/.env.example @@ -697,6 +697,8 @@ NEXT_PUBLIC_CLOUD_URL= #OPENCODE_USER_AGENT=opencode-cli/1.0.0 #OPENCODE_CLIENT=cli #OPENCODE_PROJECT=default +#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off +#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/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3f97887f..909c4308 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -325,6 +325,8 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `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_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 EXTRA tool names to declare on a keyless OpenCode request, on top of the `bash`/`glob`/`grep`/`read` quartet the upstream always requires (those four are injected whether or not this is set, and a configured name never replaces them). At most 32 entries, `[A-Za-z_][A-Za-z0-9_-]{0,63}`; invalid entries are ignored. | | `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `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. | diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 2472700e..2b180ae7 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, @@ -12,7 +11,18 @@ import { isThinkingMessageModel, } from "../utils/reasoningContentInjector.ts"; import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts"; -import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; +import { + forwardOpencodeClientHeaders, + DEFAULT_OPENCODE_USER_AGENT, +} from "../utils/opencodeHeaders.ts"; +import { + attemptFor, + fingerprintRenamesFor, + prepareFreeTierRequest, + rebuildJsonFromForcedStream, + surfaceFromBaseUrl, +} from "./opencodeFreeTierContract.ts"; +import { restoreFingerprintToolNames } from "../utils/opencodeFingerprint.ts"; import { type AccountProxyConfig, type RotatableAccount, @@ -454,6 +464,30 @@ export class OpencodeExecutor extends BaseExecutor { }; } + private finalizeForcedStream( + input: ExecuteInput, + result: ExecutorExecuteResult + ): ExecutorExecuteResult { + const restored = (r: ExecutorExecuteResult): ExecutorExecuteResult => + this.restoreFingerprintNames(input, r); + if (input.stream) return restored(result); + if (!("response" in result) || !result.response) return result; + const model = attemptFor(input.body)?.model; + if (!model) return restored(result); + const response = rebuildJsonFromForcedStream(result.response, this._requestFormat, model); + return restored(response === result.response ? result : { ...result, response }); + } + + private restoreFingerprintNames( + input: ExecuteInput, + result: ExecutorExecuteResult + ): ExecutorExecuteResult { + const renameMap = fingerprintRenamesFor(input.body); + if (!renameMap || !("response" in result) || !result.response) return result; + const response = restoreFingerprintToolNames(result.response, renameMap); + return response === result.response ? result : { ...result, response }; + } + async execute(input: ExecuteInput) { this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model); @@ -522,7 +556,10 @@ export class OpencodeExecutor extends BaseExecutor { "OPENCODE", `upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); - return this.normalizeMuseSparkResponse(input, await super.execute(input)); + return this.finalizeForcedStream( + input, + this.normalizeMuseSparkResponse(input, await super.execute(input)) + ); } log?.debug?.( "OPENCODE", @@ -530,7 +567,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 @@ -660,7 +697,7 @@ export class OpencodeExecutor extends BaseExecutor { } this.markSuccess(account); - 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 @@ -674,7 +711,10 @@ export class OpencodeExecutor extends BaseExecutor { } // All accounts returned 429 (or errored) — surface the last response. - return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input))); + return this.finalizeForcedStream( + input, + this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input))) + ); } finally { this._requestFormat = null; } @@ -749,7 +789,7 @@ export class OpencodeExecutor extends BaseExecutor { userAgent: process.env[envUAKey]?.trim() || process.env.OPENCODE_USER_AGENT?.trim() || - "opencode", + DEFAULT_OPENCODE_USER_AGENT, client: process.env.OPENCODE_CLIENT?.trim() || "desktop", project: process.env.OPENCODE_PROJECT?.trim() || "global", }; @@ -776,17 +816,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; @@ -914,6 +947,16 @@ export class OpencodeExecutor extends BaseExecutor { if (isThinkingMessageModel(model)) { modifiedBody = injectReasoningContentForThinkingModel(modifiedBody); } - return modifiedBody; + const surface = surfaceFromBaseUrl(this.config?.baseUrl); + const { body: contractBody } = prepareFreeTierRequest( + modifiedBody, + this._requestFormat, + surface, + this.config?.id || this.provider || "opencode", + model, + undefined, + typeof body === "object" && body !== null ? (body as object) : undefined + ); + return contractBody; } } diff --git a/open-sse/executors/opencodeFreeTierContract.ts b/open-sse/executors/opencodeFreeTierContract.ts new file mode 100644 index 00000000..9d118fa9 --- /dev/null +++ b/open-sse/executors/opencodeFreeTierContract.ts @@ -0,0 +1,285 @@ +/** + * 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 carrying the lowercase quartet `bash`, `glob`, `grep`, `read`, + * 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 { + OPENCODE_FINGERPRINT_TOOLS, + concealFingerprintToolNames, + fingerprintPlaceholderTool, + recordRenamedToolNames, + renamedToolNamesFor, + retargetToolChoice, +} from "../utils/opencodeFingerprint.ts"; + +export interface FreeTierContractAttempt { + readonly provider: string; + readonly model: string; + readonly session: string | undefined; + readonly borrowed: boolean; + readonly clientToolNames: readonly string[]; + readonly probe?: boolean; + readonly injectedPlaceholders?: boolean; +} + +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 { + if (provider === "opencode-go") return true; + if (model.endsWith("-free")) return false; + return !OPENCODE_FREE_MODELS.has(model); +} + +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"; +} + +function isBodyContractEnabled(): boolean { + return (process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT || "").trim().toLowerCase() !== "off"; +} + +export function isGatedFreeTierRequest( + surface: OpencodeSurface, + provider: string, + model: string +): boolean { + if (surface !== "zen") return false; + return !isPremiumOpencodeModel(model, provider); +} + +export function requiresFreeTierRequestContract( + surface: OpencodeSurface, + provider: string, + model: string +): boolean { + return isGatedFreeTierRequest(surface, provider, model) && isBodyContractEnabled(); +} + +const PLACEHOLDER_TOOL_NAMES: readonly string[] = OPENCODE_FINGERPRINT_TOOLS; +export const DEFAULT_PLACEHOLDER_TOOL_NAME = PLACEHOLDER_TOOL_NAMES[0]; + +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; +} + +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 a free-tier request up to the upstream contract, ensuring the lowercase + * quartet (`bash`, `glob`, `grep`, `read`) is present. + */ +export function applyFreeTierRequestContract( + body: T, + requestFormat: string | null, + placeholderNames: readonly string[] = PLACEHOLDER_TOOL_NAMES +): T { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + const next: Record = { ...record, stream: true }; + + const { tools: concealed, map: renamed } = concealFingerprintToolNames(next.tools); + if (next.tools !== concealed) next.tools = concealed; + if (renamed.size > 0) { + recordRenamedToolNames(next, renamed); + retargetToolChoice(next, renamed); + } + + const existingNames = new Set(clientToolNamesOf(next)); + const baseNames: string[] = [...OPENCODE_FINGERPRINT_TOOLS]; + for (const name of placeholderNames.length > 0 ? placeholderNames : PLACEHOLDER_TOOL_NAMES) { + if (!baseNames.includes(name)) baseNames.push(name); + } + const namesToAdd = baseNames.filter((name) => !existingNames.has(name)); + + if (namesToAdd.length === 0) return next as T; + + const existingTools = Array.isArray(next.tools) ? next.tools : []; + const inject = (name: string, flat: boolean): Record => + fingerprintPlaceholderTool(name, flat); + + if (requestFormat === "openai-responses") { + next.tools = [...existingTools, ...namesToAdd.map((name) => inject(name, true))]; + return next as T; + } + + if (requestFormat === "openai" || requestFormat === null) { + next.tools = [...existingTools, ...namesToAdd.map((name) => inject(name, false))]; + return next as T; + } + + return next as T; +} + +const attemptsByOrigin = new WeakMap(); + +export function attemptFor(origin: unknown): FreeTierContractAttempt | null { + if (!origin || typeof origin !== "object") return null; + return attemptsByOrigin.get(origin) ?? null; +} + +function isTrackable(value: unknown): value is object { + return typeof value === "object" && value !== null; +} + +export function prepareFreeTierRequest( + body: T, + requestFormat: string | null, + surface: OpencodeSurface, + provider: string, + model: string, + session?: string, + origin?: object +): { body: T; attempt: FreeTierContractAttempt | null } { + const clientToolNames = clientToolNamesOf(body); + if (!requiresFreeTierRequestContract(surface, provider, model)) { + if (isTrackable(origin)) attemptsByOrigin.delete(origin); + return { body, attempt: null }; + } + + const names = configuredPlaceholderToolNames(); + const attempt: FreeTierContractAttempt = { + provider, + model, + session, + borrowed: false, + clientToolNames, + probe: false, + }; + + if (isTrackable(origin)) { + attemptsByOrigin.set(origin, attempt); + } + + const preparedBody = applyFreeTierRequestContract(body, requestFormat, names); + if (isTrackable(origin)) { + const renames = renamedToolNamesFor(preparedBody); + if (renames && renames.size > 0) recordRenamedToolNames(origin, renames); + } + + return { body: preparedBody, attempt }; +} + +export function fingerprintRenamesFor(body: unknown): ReadonlyMap | null { + return renamedToolNamesFor(body); +} + +export function noteFreeTierOutcome(attempt: FreeTierContractAttempt | null, ok: boolean): void { + void attempt; + void ok; +} + +/** + * Rebuild a JSON body from the event stream the contract forced. + */ +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) { + if (!drained && !upstream.bodyUsed && upstream.body && !upstream.body.locked) { + void upstream.body.cancel(reason); + } + }, + }, + { 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/utils/opencodeFingerprint.ts b/open-sse/utils/opencodeFingerprint.ts new file mode 100644 index 00000000..93a4a8d2 --- /dev/null +++ b/open-sse/utils/opencodeFingerprint.ts @@ -0,0 +1,431 @@ +/** + * opencodeFingerprint.ts — the client fingerprint OpenCode Zen's free tier requires. + * + * Live probes against https://opencode.ai/zen/v1 on 2026-10-02 / 2026-10-03 show the gate + * inspects tool names and casing. Independent verification by @espokaos-ops across 121 direct + * probe records on 9 gated free models (both /chat/completions and /responses, see #15322) + * shows that the lowercase pair `bash` + `read` is the minimal passing combination: + * + * no tools ................................ 403 FreeTierError (9/9 models) + * one placeholder tool only (`_noop`) ..... 403 FreeTierError (9/9 models) + * one client tool only (`my_custom_tool`) . 403 FreeTierError (9/9 models) + * `Bash + Read` (wrong case) .............. 403 FreeTierError (9/9 models) + * `bash` only / `read` only ............... 403 FreeTierError (4/9 models) + * `bash` + `glob` (incomplete) ............ 403 FreeTierError (3/9 models) + * bash + read (minimal pair) .............. 200 (9/9 models) + * bash + glob + grep + read (quartet) ..... 200 (9/9 models) + * + * The production constant `OPENCODE_FINGERPRINT_TOOLS` preserves the file-search quartet + * `bash`, `glob`, `grep`, `read` as a conservative safety margin closer to the real OpenCode + * client's toolset. (9router originally introduced `bash + read` in 93837af09 and bumped to the + * quartet in 822aa958d without a documented rationale; keeping the quartet guards against + * upstream gate tightening). + * + * Order does not matter and extra tools alongside the quartet are accepted, so the pass + * appends only the genuinely missing members. A client's own capitalization (`Bash`, + * `Read`) must be canonicalised rather than duplicated: the capitalised spelling alone is + * refused, and sending both spellings passes only because the canonical one is present — + * so the request side renames the quartet and the response side restores the caller's + * original spelling, letting agent clients such as Claude Code still recognise their own + * tool calls. + * + * Leaf module: no internal imports, so the free-tier contract and the executor can both + * use it without a cycle. + */ + +/** Canonical names the upstream free-tier gate requires (conservatively kept as quartet). */ +export const OPENCODE_FINGERPRINT_TOOLS: readonly string[] = ["bash", "glob", "grep", "read"]; + +/** + * Smallest combination measured to pass the upstream gate (200) across all 9 gated models + * (measured by @espokaos-ops on 2026-10-03 directly against https://opencode.ai/zen/v1). + */ +export const OPENCODE_MEASURED_MINIMUM_FINGERPRINT_TOOLS: readonly string[] = ["bash", "read"]; + +const FINGERPRINT_SET: ReadonlySet = new Set(OPENCODE_FINGERPRINT_TOOLS); + +/** + * Description for a quartet member this layer injected. The injection exists only to + * satisfy the gate, so the text steers the model away from ever selecting it. + */ +export const FINGERPRINT_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; + +/** Canonical lowercase name when `name` is a quartet member; "" otherwise. */ +export function fingerprintToolKey(name: unknown): string { + const lower = String(name ?? "") + .trim() + .toLowerCase(); + return FINGERPRINT_SET.has(lower) ? lower : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read a tool name from either flat ({name}) or chat ({function:{name}}) shape. */ +function toolNameOf(tool: Record): string { + if (typeof tool.name === "string" && tool.name.trim()) return tool.name.trim(); + const fn = tool.function; + if (isRecord(fn) && typeof fn.name === "string") return fn.name.trim(); + return ""; +} + +export interface FingerprintConcealResult { + readonly tools: unknown; + /** sent canonical name -> the caller's original spelling */ + readonly map: Map; +} + +/** + * Canonicalise only the fingerprint quartet and drop duplicate quartet variants. + * + * Non-quartet tools are preserved verbatim, including tools whose names differ only by + * case — they are outside the upstream's fingerprint contract. + */ +export function concealFingerprintToolNames(tools: unknown): FingerprintConcealResult { + const map = new Map(); + if (!Array.isArray(tools) || tools.length === 0) return { tools, map }; + + const seenQuartet = new Set(); + const out: unknown[] = []; + // Nothing to canonicalise is the common case; keeping the original array identity lets + // callers skip re-serialising. + let changed = false; + for (const tool of tools) { + if (!isRecord(tool)) { + out.push(tool); + continue; + } + + const current = toolNameOf(tool); + const key = fingerprintToolKey(current); + if (!key) { + out.push(tool); + continue; + } + + // `Bash` + `bash` is a duplicate declaration; keep exactly one per quartet member. + if (seenQuartet.has(key)) { + changed = true; + continue; + } + seenQuartet.add(key); + + if (current !== key) { + changed = true; + map.set(key, current); + const fn = isRecord(tool.function) ? tool.function : null; + out.push(fn ? { ...tool, function: { ...fn, name: key } } : { ...tool, name: key }); + } else { + out.push(tool); + } + } + return { tools: changed ? out : tools, map }; +} + +/** Point an explicit tool_choice at a quartet member after canonicalisation. */ +export function retargetToolChoice(body: unknown, map: ReadonlyMap): void { + if (!isRecord(body) || map.size === 0) return; + const choice = body.tool_choice; + if (!isRecord(choice)) return; + + if (typeof choice.name === "string") { + const key = fingerprintToolKey(choice.name); + if (key && map.has(key)) body.tool_choice = { ...choice, name: key }; + return; + } + + const fn = isRecord(choice.function) ? choice.function : null; + if (fn && typeof fn.name === "string") { + const key = fingerprintToolKey(fn.name); + if (key && map.has(key)) body.tool_choice = { ...choice, function: { ...fn, name: key } }; + } +} + +/** Rename maps by body identity, so a shared executor never crosses two in-flight requests. */ +const renamedToolNames = new WeakMap>(); + +/** Store the rename map for `body`. */ +export function recordRenamedToolNames(body: unknown, map: ReadonlyMap): void { + if (!isRecord(body) || map.size === 0) return; + renamedToolNames.set(body, new Map(map)); +} + +/** Retrieve the rename map recorded for `body`, or null when nothing was renamed. */ +export function renamedToolNamesFor(body: unknown): ReadonlyMap | null { + if (!isRecord(body)) return null; + return renamedToolNames.get(body) ?? null; +} + +/** + * Build the list entry this layer injects for a missing quartet member. + * + * A list entry with an empty parameter object and a description telling the model not to + * call it: the injection exists to satisfy the gate, never to be selected. + * + * @param flat true for the Responses shape ({name}), false for Chat Completions + * ({function:{name}}) + */ +export function fingerprintPlaceholderTool(name: string, flat: boolean): Record { + return flat + ? { + type: "function", + name, + description: FINGERPRINT_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + } + : { + type: "function", + function: { + name, + description: FINGERPRINT_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + }, + }; +} + +/** + * Restore the caller's tool spellings in every response/event shape the opencode family + * can emit: Claude streaming and JSON blocks, OpenAI Chat Completions deltas and JSON + * messages, and the Responses API JSON body plus its SSE `item` events. + * + * Returns the input unchanged (same identity) when nothing matched, so callers can use + * identity to skip re-serializing. + */ +export function restoreToolNames(payload: unknown, map: ReadonlyMap): unknown { + if (map.size === 0 || payload === null || typeof payload !== "object") return payload; + if (Array.isArray(payload)) return payload.map((item) => restoreToolNames(item, map)); + + const record = payload as Record; + let out = record; + const put = (key: string, value: unknown): void => { + if (out === record) out = { ...record }; + out[key] = value; + }; + + // Claude streaming content_block_start event. + const block = record.content_block; + if ( + record.type === "content_block_start" && + isRecord(block) && + block.type === "tool_use" && + typeof block.name === "string" && + map.has(block.name) + ) { + put("content_block", { ...block, name: map.get(block.name) }); + } + + // Claude non-streaming message body. + if (Array.isArray(record.content)) { + let changed = false; + const next = record.content.map((item) => { + if ( + isRecord(item) && + item.type === "tool_use" && + typeof item.name === "string" && + map.has(item.name) + ) { + changed = true; + return { ...item, name: map.get(item.name) }; + } + return item; + }); + if (changed) put("content", next); + } + + // OpenAI Chat Completions, both the streaming delta and the JSON message shapes. + if (Array.isArray(record.choices)) { + let changed = false; + const next = record.choices.map((choice) => { + if (!isRecord(choice)) return choice; + let choiceChanged = false; + const updated: Record = { ...choice }; + for (const holder of ["delta", "message"] as const) { + const value = choice[holder]; + if (!isRecord(value) || !Array.isArray(value.tool_calls) || value.tool_calls.length === 0) { + continue; + } + let callsChanged = false; + const calls = value.tool_calls.map((call) => { + if (!isRecord(call)) return call; + const fn = call.function; + if (isRecord(fn) && typeof fn.name === "string" && map.has(fn.name)) { + callsChanged = true; + return { ...call, function: { ...fn, name: map.get(fn.name) } }; + } + return call; + }); + if (callsChanged) { + updated[holder] = { ...value, tool_calls: calls }; + choiceChanged = true; + } + } + if (!choiceChanged) return choice; + changed = true; + return updated; + }); + if (changed) put("choices", next); + } + + // OpenAI Responses final JSON body. + if (Array.isArray(record.output)) { + let changed = false; + const next = record.output.map((item) => { + if ( + isRecord(item) && + item.type === "function_call" && + typeof item.name === "string" && + map.has(item.name) + ) { + changed = true; + return { ...item, name: map.get(item.name) }; + } + return item; + }); + if (changed) put("output", next); + } + + // OpenAI Responses SSE events such as response.output_item.added / .done. + const item = record.item; + if ( + isRecord(item) && + item.type === "function_call" && + typeof item.name === "string" && + map.has(item.name) + ) { + put("item", { ...item, name: map.get(item.name) }); + } + + return out; +} + +/** Rewrite one SSE line, leaving non-data lines and unparseable payloads untouched. */ +function rewriteDataLine(line: string, map: ReadonlyMap): string { + const trimmed = line.trimStart(); + if (!trimmed.startsWith("data:")) return line; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") return line; + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return line; + } + const restored = restoreToolNames(parsed, map); + if (restored === parsed) return line; + return `data: ${JSON.stringify(restored)}`; +} + +function restoreJsonBody(upstream: Response, map: ReadonlyMap): Response { + let drained = false; + const body = new ReadableStream( + { + async pull(controller) { + if (drained) { + controller.close(); + return; + } + drained = true; + try { + const text = await upstream.text(); + let out = text; + try { + const parsed: unknown = JSON.parse(text); + out = JSON.stringify(restoreToolNames(parsed, map)); + } catch { + /* not JSON — forward verbatim */ + } + controller.enqueue(new TextEncoder().encode(out)); + } catch (err) { + controller.error(err); + return; + } + controller.close(); + }, + cancel(reason) { + if (!drained && !upstream.bodyUsed && upstream.body && !upstream.body.locked) { + void upstream.body.cancel(reason); + } + }, + }, + { highWaterMark: 0 } + ); + const headers = new Headers(upstream.headers); + headers.delete("content-length"); + return new Response(body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + +function restoreSseBody(upstream: Response, map: ReadonlyMap): Response { + const reader = upstream.body!.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let closed = false; + const stream = new ReadableStream({ + async start(controller) { + try { + while (!closed) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + if (buffer.length > 0 && !closed) { + controller.enqueue(encoder.encode(rewriteDataLine(buffer, map))); + } + if (!closed) { + closed = true; + controller.close(); + } + return; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + controller.enqueue(encoder.encode(rewriteDataLine(line, map) + "\n")); + } + } + } catch (err) { + if (!closed) { + closed = true; + controller.error(err); + } + } + }, + cancel(reason) { + closed = true; + reader.cancel(reason).catch(() => undefined); + }, + }); + return new Response(stream, { + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers, + }); +} + +/** + * Wrap a successful upstream response so the caller sees its own tool spellings again. + * + * Applied at the outermost layer of the executor, AFTER a forced stream has been rebuilt + * into JSON, so both the streaming and the buffered client paths are covered by one pass. + * A refusal (or an empty body) passes through untouched — there is nothing to restore, and + * the executor still needs to read those bodies itself. + */ +export function restoreFingerprintToolNames( + response: Response, + map: ReadonlyMap | null +): Response { + if (!map || map.size === 0 || !response.ok || !response.body) return response; + const renameMap = map; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("text/event-stream")) return restoreSseBody(response, renameMap); + if (contentType.includes("application/json")) return restoreJsonBody(response, renameMap); + return response; +} diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 398d53c0..ab0ee1af 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -1,7 +1,48 @@ -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); +} + +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). + */ +export 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. @@ -49,7 +90,7 @@ function findHeader(headers: Record, name: string): string | und * (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session * UUIDs, but ONLY for keys the client did not already supply. Client values always * win; these defaults only fill gaps. User-Agent is the one exception: a client UA - * that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the + * that is not already the OpenCode CLI is REPLACED with the * synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs * from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229) * @param options.sessionBody - Request body fields used to generate a @@ -119,7 +160,7 @@ export function forwardOpencodeClientHeaders( * User-Agent is the exception: a non-CLI client UA (curl, python, SDKs) is replaced * with the synthesized CLI UA, because opencode.ai's free tier flags generic client * UAs from datacenter IPs (FreeUsageLimitError 429). A client UA that already looks - * like the OpenCode CLI (opencode-cli/...) is preserved so the real CLI's versioned + * like the OpenCode CLI is preserved so the real CLI's versioned * identity stays intact. (#5997, follow-up) */ function applyCliDefaults( @@ -133,14 +174,20 @@ function applyCliDefaults( } ): void { 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(); + 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 02ed7df0..e72ef4c3 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-free-tier-fingerprint.test.ts b/tests/unit/opencode-free-tier-fingerprint.test.ts new file mode 100644 index 00000000..ff0b0e45 --- /dev/null +++ b/tests/unit/opencode-free-tier-fingerprint.test.ts @@ -0,0 +1,403 @@ +/** + * OpenCode Zen free-tier client fingerprint. + * + * The upstream gate inspects tool names and casing. Measured live against + * https://opencode.ai/zen/v1 on 2026-10-02, and independently confirmed by @espokaos-ops + * on 2026-10-03 (121 probe records across 9 gated free models, #15322): + * + * no tools ................................ 403 FreeTierError (9/9 models) + * `_noop` placeholder only ................ 403 FreeTierError (9/9 models) + * one client tool only .................... 403 FreeTierError (9/9 models) + * `Bash + Read` (wrong case) .............. 403 FreeTierError (9/9 models) + * `bash` only / `read` only ............... 403 FreeTierError (4/9 models) + * `bash` + `glob` (incomplete) ............ 403 FreeTierError (3/9 models) + * bash + read (minimal pair) .............. 200 (9/9 models) + * bash + glob + grep + read (quartet) ..... 200 (9/9 models) + * + * The production constant `OPENCODE_FINGERPRINT_TOOLS` keeps the quartet as a conservative + * safety margin closer to the real client's fingerprint. + * + * Order is irrelevant, extra tools alongside the quartet are accepted, and a client that + * spells a member differently (`Bash`, `Read`) must be renamed rather than duplicated: + * sending both spellings passes only because the canonical one is present. These tests pin + * that behaviour and the response-side restoration that gives the caller its own spelling + * back. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + OPENCODE_FINGERPRINT_TOOLS, + OPENCODE_MEASURED_MINIMUM_FINGERPRINT_TOOLS, + concealFingerprintToolNames, + fingerprintPlaceholderTool, + fingerprintToolKey, + renamedToolNamesFor, + restoreFingerprintToolNames, + restoreToolNames, + retargetToolChoice, +} from "../../open-sse/utils/opencodeFingerprint.ts"; +import { + applyFreeTierRequestContract, + isGatedFreeTierRequest, +} from "../../open-sse/executors/opencodeFreeTierContract.ts"; +import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeaders.ts"; + +const chatTool = (name: string) => ({ + type: "function", + function: { name, description: `${name} desc`, parameters: { type: "object" } }, +}); +const flatTool = (name: string) => ({ + type: "function", + name, + description: `${name} desc`, + parameters: { type: "object" }, +}); + +type ChatTool = { type: string; function: { name: string } }; +type FlatTool = { type: string; name: string }; + +const namesOf = (tools: unknown): string[] => (tools as ChatTool[]).map((t) => t.function.name); +const flatNamesOf = (tools: unknown): string[] => (tools as FlatTool[]).map((t) => t.name); + +test("the required fingerprint set is exactly the lowercase file-search quartet", () => { + assert.deepEqual(OPENCODE_FINGERPRINT_TOOLS, ["bash", "glob", "grep", "read"]); +}); + +test("the measured minimum passing combination is {bash, read} as verified on 2026-10-03 (#15322)", () => { + assert.deepEqual(OPENCODE_MEASURED_MINIMUM_FINGERPRINT_TOOLS, ["bash", "read"]); + // The quartet kept in production is a superset of the measured minimum: + for (const minMember of OPENCODE_MEASURED_MINIMUM_FINGERPRINT_TOOLS) { + assert.ok( + OPENCODE_FINGERPRINT_TOOLS.includes(minMember), + `quartet must contain measured minimum member ${minMember}` + ); + } +}); + +test("fingerprintToolKey recognises quartet members case-insensitively and nothing else", () => { + assert.equal(fingerprintToolKey("bash"), "bash"); + assert.equal(fingerprintToolKey("BASH"), "bash"); + assert.equal(fingerprintToolKey(" Read "), "read"); + assert.equal(fingerprintToolKey("Glob"), "glob"); + assert.equal(fingerprintToolKey("my_custom_tool"), ""); + assert.equal(fingerprintToolKey(""), ""); + assert.equal(fingerprintToolKey(undefined), ""); + assert.equal(fingerprintToolKey("bash2"), ""); +}); + +test("concealFingerprintToolNames renames case variants and keeps the caller's schema", () => { + const tools = [chatTool("Bash"), chatTool("Read")]; + const { tools: out, map } = concealFingerprintToolNames(tools); + assert.deepEqual(namesOf(out), ["bash", "read"]); + // The rename map is what lets the response hand `Bash` back to the caller. + assert.deepEqual( + [...map.entries()], + [ + ["bash", "Bash"], + ["read", "Read"], + ] + ); + // Schema and description survive the rename: only the name changes. + const renamed = (out as ChatTool[])[0] as unknown as { + function: { description: string; parameters: unknown }; + }; + assert.equal(renamed.function.description, "Bash desc"); + assert.deepEqual(renamed.function.parameters, { type: "object" }); +}); + +test("concealFingerprintToolNames handles the flat Responses shape", () => { + const { tools: out, map } = concealFingerprintToolNames([flatTool("Glob")]); + assert.deepEqual(flatNamesOf(out), ["glob"]); + assert.deepEqual([...map.entries()], [["glob", "Glob"]]); +}); + +test("concealFingerprintToolNames drops duplicate quartet declarations", () => { + // `Bash` + `bash` in one body is a duplicate declaration of the same quartet member. + const { tools: out, map } = concealFingerprintToolNames([chatTool("Bash"), chatTool("bash")]); + assert.deepEqual(namesOf(out), ["bash"]); + assert.deepEqual([...map.entries()], [["bash", "Bash"]]); +}); + +test("concealFingerprintToolNames leaves non-quartet tools and the array identity alone", () => { + const tools = [chatTool("my_custom_tool"), chatTool("webfetch")]; + const { tools: out, map } = concealFingerprintToolNames(tools); + assert.equal(out, tools, "nothing to rename must not rebuild the array"); + assert.equal(map.size, 0); +}); + +test("concealFingerprintToolNames preserves a quartet member that is already canonical", () => { + const tools = [chatTool("bash"), chatTool("read")]; + const { tools: out, map } = concealFingerprintToolNames(tools); + assert.equal(out, tools, "canonical spelling is already correct"); + assert.equal(map.size, 0); +}); + +test("the contract completes an incomplete quartet", () => { + const body = prepare({ + messages: [], + tools: [chatTool("bash"), chatTool("glob")], + }); + assert.deepEqual(namesOf(body.tools), ["bash", "glob", "grep", "read"]); +}); + +test("the contract adds nothing when the quartet is already complete", () => { + const complete = OPENCODE_FINGERPRINT_TOOLS.map(chatTool); + const body = prepare({ messages: [], tools: complete }); + assert.deepEqual(namesOf(body.tools), ["bash", "glob", "grep", "read"]); +}); + +test("the contract uses the flat shape on the Responses surface", () => { + const body = prepare({ input: [] }, "openai-responses"); + assert.deepEqual(flatNamesOf(body.tools), ["bash", "glob", "grep", "read"]); + assert.equal((body.tools as FlatTool[])[0].type, "function"); + // Flat shape: the name is a sibling of `type`, not nested under `function`. + assert.equal("function" in (body.tools as object[])[0], false); +}); + +test("retargetToolChoice points a renamed member's explicit choice at the sent name", () => { + const map = new Map([["bash", "Bash"]]); + const nested = { tool_choice: { type: "function", function: { name: "Bash" } } }; + retargetToolChoice(nested, map); + assert.deepEqual(nested.tool_choice, { type: "function", function: { name: "bash" } }); + + const flat = { tool_choice: { type: "function", name: "Bash" } }; + retargetToolChoice(flat, map); + assert.deepEqual(flat.tool_choice, { type: "function", name: "bash" }); +}); + +test("retargetToolChoice leaves a choice the map does not mention untouched", () => { + const body = { tool_choice: { type: "function", function: { name: "my_custom_tool" } } }; + const before = structuredClone(body); + retargetToolChoice(body, new Map([["bash", "Bash"]])); + assert.deepEqual(body, before); +}); + +test("retargetToolChoice tolerates a string choice and an empty map", () => { + const body: Record = { tool_choice: "auto" }; + retargetToolChoice(body, new Map([["bash", "Bash"]])); + assert.equal(body.tool_choice, "auto"); + const other: Record = { tool_choice: 42 }; + retargetToolChoice(other, new Map()); + assert.equal(other.tool_choice, 42); +}); + +test("fingerprintPlaceholderTool stamps the sentinel description and an empty schema", () => { + const flat = fingerprintPlaceholderTool("bash", true); + assert.equal(flat.type, "function"); + assert.equal(flat.name, "bash"); + assert.deepEqual(flat.parameters, { type: "object", properties: {} }); + assert.match(String(flat.description), /never be invoked/i); + + const nested = fingerprintPlaceholderTool("bash", false) as { + function: { name: string; parameters: unknown }; + }; + assert.equal(nested.function.name, "bash"); + assert.deepEqual(nested.function.parameters, { type: "object", properties: {} }); +}); + +// ── End to end through the production request path ──────────────────────────────────── + +/** The request-side pass the executor actually runs, via the free-tier contract. */ +function prepare(body: Record, format = "openai", model = "big-pickle") { + return applyFreeTierRequestContract({ ...body, model }, format) as Record; +} + +test("a gated request is canonicalised, completed and records the renames", () => { + const body = prepare({ + messages: [{ role: "user", content: "hi" }], + tools: [chatTool("Bash"), chatTool("my_custom_tool")], + }); + assert.deepEqual(namesOf(body.tools), ["bash", "my_custom_tool", "glob", "grep", "read"]); + assert.deepEqual([...renamedToolNamesFor(body)!.entries()], [["bash", "Bash"]]); +}); + +test("an explicit tool_choice naming a renamed member follows the rename", () => { + // Otherwise the request would name a tool it no longer declares. + const body = prepare({ + messages: [{ role: "user", content: "hi" }], + tools: [chatTool("Bash")], + tool_choice: { type: "function", function: { name: "Bash" } }, + }); + assert.deepEqual(body.tool_choice, { type: "function", function: { name: "bash" } }); +}); + +test("tool_choice is never imposed on a chat request", () => { + // The upstream answers 400 `only "auto" is supported for tool_choice` (measured + // 2026-09-18), so a default must not be added on this surface. + const body = prepare({ messages: [{ role: "user", content: "hi" }] }); + assert.equal("tool_choice" in body, false); +}); + +test("applying the production pass twice does not duplicate the quartet", () => { + const once = prepare({ messages: [], tools: [chatTool("Bash")] }); + const twice = prepare(once); + assert.deepEqual(namesOf(twice.tools), ["bash", "glob", "grep", "read"]); +}); + +// ── Response side: the caller gets its own spelling back ───────────────────────────── + +test("restoreToolNames rewrites an OpenAI Chat Completions delta tool call", () => { + const payload = { + choices: [{ delta: { tool_calls: [{ function: { name: "bash", arguments: "{}" } }] } }], + }; + const out = restoreToolNames(payload, new Map([["bash", "Bash"]])) as typeof payload; + assert.equal(out.choices[0].delta.tool_calls[0].function.name, "Bash"); + // The arguments are untouched. + assert.equal(out.choices[0].delta.tool_calls[0].function.arguments, "{}"); +}); + +test("restoreToolNames rewrites an OpenAI Chat Completions JSON message tool call", () => { + const payload = { + choices: [{ message: { tool_calls: [{ function: { name: "read", arguments: "{}" } }] } }], + }; + const out = restoreToolNames(payload, new Map([["read", "Read"]])) as typeof payload; + assert.equal(out.choices[0].message.tool_calls[0].function.name, "Read"); +}); + +test("restoreToolNames rewrites Claude streaming and JSON tool_use blocks", () => { + const streamEvent = { + type: "content_block_start", + content_block: { type: "tool_use", name: "bash", input: {} }, + }; + const restored = restoreToolNames(streamEvent, new Map([["bash", "Bash"]])) as typeof streamEvent; + assert.equal(restored.content_block.name, "Bash"); + + const message = { + content: [ + { type: "tool_use", name: "glob", input: {} }, + { type: "text", text: "hi" }, + ], + }; + const restoredMessage = restoreToolNames(message, new Map([["glob", "Glob"]])) as typeof message; + assert.equal(restoredMessage.content[0].name, "Glob"); + assert.equal(restoredMessage.content[1].type, "text"); +}); + +test("restoreToolNames rewrites OpenAI Responses output items and SSE item events", () => { + const body = { output: [{ type: "function_call", name: "read", call_id: "c1" }] }; + const restored = restoreToolNames(body, new Map([["read", "Read"]])) as typeof body; + assert.equal(restored.output[0].name, "Read"); + + const event = { + type: "response.output_item.added", + item: { type: "function_call", name: "grep" }, + }; + const restoredEvent = restoreToolNames(event, new Map([["grep", "Grep"]])) as typeof event; + assert.equal(restoredEvent.item.name, "Grep"); +}); + +test("restoreToolNames keeps identity when nothing matches, so serialisation can be skipped", () => { + const payload = { choices: [{ message: { tool_calls: [{ function: { name: "other" } }] } }] }; + assert.equal(restoreToolNames(payload, new Map([["bash", "Bash"]])), payload); + assert.equal(restoreToolNames(payload, new Map()), payload); + assert.equal(restoreToolNames("a string", new Map([["bash", "Bash"]])), "a string"); + assert.equal(restoreToolNames(null, new Map([["bash", "Bash"]])), null); +}); + +test("restoreFingerprintToolNames passes a response through when there is nothing to restore", () => { + const response = new Response("{}", { headers: { "content-type": "application/json" } }); + assert.equal(restoreFingerprintToolNames(response, null), response); + assert.equal(restoreFingerprintToolNames(response, new Map()), response); + // A refusal has nothing to restore and the executor still needs to read its body. + const refusal = new Response("{}", { + status: 403, + headers: { "content-type": "application/json" }, + }); + assert.equal(restoreFingerprintToolNames(refusal, new Map([["bash", "Bash"]])), refusal); +}); + +test("restoreFingerprintToolNames restores names in a streaming SSE response", async () => { + const sse = [ + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ function: { name: "bash" } }] } }] })}`, + "", + `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ function: { name: "read" } }] } }] })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + const upstream = new Response(sse, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const restored = restoreFingerprintToolNames( + upstream, + new Map([ + ["bash", "Bash"], + ["read", "Read"], + ]) + ); + const text = await restored.text(); + assert.match(text, /"name":"Bash"/); + assert.match(text, /"name":"Read"/); + // A terminal line survives the pass. + assert.match(text, /\[DONE\]/); +}); + +test("restoreFingerprintToolNames restores names in a JSON response", async () => { + const upstream = new Response( + JSON.stringify({ choices: [{ message: { tool_calls: [{ function: { name: "glob" } }] } }] }), + { status: 200, headers: { "content-type": "application/json; charset=utf-8" } } + ); + const restored = restoreFingerprintToolNames(upstream, new Map([["glob", "Glob"]])); + const json = (await restored.json()) as { + choices: Array<{ message: { tool_calls: Array<{ function: { name: string } }> } }>; + }; + assert.equal(json.choices[0].message.tool_calls[0].function.name, "Glob"); +}); + +test("restoreFingerprintToolNames leaves a non-JSON, non-SSE body untouched", () => { + const upstream = new Response("plain text", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + assert.equal(restoreFingerprintToolNames(upstream, new Map([["bash", "Bash"]])), upstream); +}); + +// ── End to end: the contract + fingerprint together produce a gate-passing body ──────── + +test("a Claude Code style request is canonicalised so the gate sees the lowercase quartet", () => { + // Claude Code declares Bash/Read/Glob/Grep with capital letters. That alone is refused, + // and sending both spellings passes only because the canonical one is present — so the + // request must carry the lowercase quartet. + const body = applyFreeTierRequestContract( + { + model: "big-pickle", + messages: [{ role: "user", content: "hi" }], + tools: ["Bash", "Read", "Glob", "Grep"].map(chatTool), + }, + "openai" + ) as Record; + + const names = namesOf(body.tools); + for (const member of OPENCODE_FINGERPRINT_TOOLS) { + assert.ok(names.includes(member), `quartet member ${member} must be declared`); + } + // Exactly one declaration per quartet member — never a capitalised duplicate. + assert.equal(names.length, 4, `expected only the quartet, got ${names.join(",")}`); +}); + +test("the gated scope predicate covers the free models this fingerprint applies to", () => { + assert.equal(isGatedFreeTierRequest("zen", "opencode", "big-pickle"), true); + assert.equal(isGatedFreeTierRequest("zen", "opencode", "muse-spark-1.3-contributor-free"), true); + // Paid models on the same host are not inspected upstream. + assert.equal(isGatedFreeTierRequest("zen", "opencode", "gpt-5.6-luna"), false); + // The /zen/go/v1 surface and other providers are out of scope. + assert.equal(isGatedFreeTierRequest("go", "opencode-go", "grok-4.5"), false); + assert.equal(isGatedFreeTierRequest("other", "openai", "gpt-5.6-luna"), false); +}); + +test("the CLI identity headers the same gate validates are still synthesized", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + {}, + { + synthesizeRequestId: true, + cliDefaults: { userAgent: "opencode/1.18.31", client: "desktop", project: "global" }, + } + ); + assert.equal(headers["User-Agent"], "opencode/1.18.31"); + assert.equal(headers["x-opencode-client"], "desktop"); + assert.equal(headers["x-opencode-project"], "global"); + assert.match(headers["x-opencode-session"], /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/); +}); diff --git a/tests/unit/opencode-session-fingerprint-headers-10571.test.ts b/tests/unit/opencode-session-fingerprint-headers-10571.test.ts index f05da453..0c70b180 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 6c16d9d7..76f7d6ad 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", () => {