fix(opencode): match the upstream free-tier request contract (#14013)

Match the upstream OpenCode free-tier request contract (issue #13935): canonical
ses_/msg_ identity ids, versioned User-Agent, and the measured body requirements
(stream:true + non-empty tools) with a learn-and-reuse tool-name cache, so
no-auth oc/* requests stop being refused with 403 FreeTierError.

Supersedes #13937 (session regex and minimum-version rule kept, credited below).
Complements #14011 (refusal classification) and #13819 (stream_options strip),
both already merged.

Closes #13935

Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Dizzle
2026-09-18 17:57:10 +02:00
committed by GitHub
parent 2ebafaedce
commit 0349627c86
12 changed files with 1609 additions and 142 deletions

View File

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

View File

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

View File

@@ -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 `<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 `<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/<version >= 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. |

View File

@@ -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<string, unknown>) : 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

View File

@@ -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/<version>` 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<string, unknown>): 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<T>(
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<string, unknown>;
const next: Record<string, unknown> = { ...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<string, unknown>).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<T>(
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<Uint8Array>(
{
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,
});
}

View File

@@ -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<string, readonly string[]>();
const consecutiveRefusals = new Map<string, number>();
/**
* 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();
}

View File

@@ -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 <PROVIDER>_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<string, string> | 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 (`<prefix>` + 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<string, string>, 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);
}

View File

@@ -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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
const clientHeaders = { "User-Agent": "opencode-cli/2.5.0" };
forwardOpencodeClientHeaders(headers, clientHeaders, { cliDefaults: CLI_DEFAULTS });
assert.equal(headers["User-Agent"], "opencode-cli/2.5.0");
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<string, string> = {};
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);
});
});

View File

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

View File

@@ -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/<version >= 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, string> = {};
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<string, unknown>) => {
const headers: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>> = [];
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<string, unknown>), false);
assert.equal("stream" in (body as Record<string, unknown>), 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<string, unknown>).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<string, unknown>).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<string, unknown>), false);
assert.equal("stream" in (body as Record<string, unknown>), 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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
});

View File

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

View File

@@ -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/<version >= 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", () => {