mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
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:
24
.env.example
24
.env.example
@@ -763,14 +763,26 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
|
||||
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
|
||||
# When your clients don't already send them, set this to synthesize the CLI headers
|
||||
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
|
||||
# absent keys. OFF by default — forward-only is safer when clients already send them.
|
||||
# (User-Agent, x-opencode-client, x-opencode-project, canonical request/session ids) on
|
||||
# absent keys. ON by default — a client value always wins, these only fill gaps.
|
||||
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
|
||||
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
|
||||
# OPENCODE_PROJECT (defaults: opencode/1.18.31 / desktop / global).
|
||||
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
|
||||
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
|
||||
#OPENCODE_CLIENT=cli
|
||||
#OPENCODE_PROJECT=default
|
||||
#OPENCODE_USER_AGENT=opencode/1.18.31
|
||||
#OPENCODE_CLIENT=desktop
|
||||
#OPENCODE_PROJECT=global
|
||||
|
||||
# Keyless OpenCode models are answered only when the request declares a non-empty tool
|
||||
# list, and the upstream inspects which names it carries. OmniRoute reuses the list a
|
||||
# request of the same conversation was last seen getting through, so a request that
|
||||
# carries none — a title or a summary — goes out with the list its own client already
|
||||
# declared. Set to off to stop adjusting request bodies entirely; headers are unaffected.
|
||||
#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off
|
||||
|
||||
# Tool names to declare when nothing has been observed yet for a model, comma-separated.
|
||||
# Empty falls back to a single placeholder the model is told not to call. Only useful on
|
||||
# an install where no client sends tools, since there is then nothing to learn from.
|
||||
#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
|
||||
@@ -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)
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
373
open-sse/executors/opencodeFreeTierContract.ts
Normal file
373
open-sse/executors/opencodeFreeTierContract.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
163
open-sse/executors/opencodeToolObservation.ts
Normal file
163
open-sse/executors/opencodeToolObservation.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
752
tests/unit/opencode-free-tier-request-contract.test.ts
Normal file
752
tests/unit/opencode-free-tier-request-contract.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user