Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
164 changes: 84 additions & 80 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import {
BaseExecutor,
type ExecuteInput,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?.(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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";
}

Expand All @@ -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 ?? {}, {
Expand All @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading