Skip to content
Closed
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
21 changes: 10 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -718,17 +718,16 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/usage
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings

# 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.
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
#OPENCODE_CLIENT=cli
#OPENCODE_PROJECT=default
# OpenCode Go/Zen VPS egress (#5997) + free-tier identity (#13935): upstream 403s chat
# requests that lack OpenCode CLI identity headers. Synthesis is ON by default (opt out
# with OPENCODE_SYNTHESIZE_CLI_HEADERS=false): User-Agent resolves live from the npm
# registry (opencode-ai/latest, 6h cache, pinned fallback offline), x-opencode-client /
# project default to desktop / global, and session/request ids use canonical ses_/msg_
# format. Client values that already satisfy the upstream contract always win.
#OPENCODE_SYNTHESIZE_CLI_HEADERS=false
#OPENCODE_USER_AGENT=opencode/1.18.31
#OPENCODE_CLIENT=desktop
#OPENCODE_PROJECT=global

# 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):** send free-tier requests with a versioned OpenCode User-Agent and canonical `ses_...` / `msg_...` identity headers (the header half of the upstream free-tier contract; body half tracked separately).
1 change: 1 addition & 0 deletions open-sse/executors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
"opencode-zen": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")),
"opencode-go": () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-go")),
opencode: () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode-zen")), // Alias for opencode-zen
oc: () => import("./opencode.ts").then((m) => new m.OpencodeExecutor("opencode")), // No-auth OpenCode Free alias (registry id "opencode", alias "oc")
vertex: () => import("./vertex.ts").then((m) => new m.VertexExecutor()),
"vertex-partner": () => import("./vertex.ts").then((m) => new m.VertexExecutor()),
cliproxyapi: () => import("./cliproxyapi.ts").then((m) => new m.CliproxyapiExecutor()),
Expand Down
39 changes: 19 additions & 20 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,12 @@ import {
runWithDirectFetchContext,
runWithProxyContext,
} from "../utils/proxyFetch.ts";
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import {
forwardOpencodeClientHeaders,
getDefaultOpencodeUserAgent,
isOpencodeCliSynthesisEnabled,
} from "../utils/opencodeHeaders.ts";
import { refreshOpencodeCliVersion } from "../utils/opencodeCliVersion.ts";
import {
type AccountProxyConfig,
type RotatableAccount,
Expand Down Expand Up @@ -505,6 +509,7 @@ export class OpencodeExecutor extends BaseExecutor {
}

async execute(input: ExecuteInput) {
if (isOpencodeCliSynthesisEnabled()) void refreshOpencodeCliVersion();
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);

// #8681: Gate premium opencode models behind a usable API key.
Expand Down Expand Up @@ -989,6 +994,11 @@ export class OpencodeExecutor extends BaseExecutor {
} else {
headers["Authorization"] = `Bearer ${key}`;
}
} else if (this.provider === "opencode" || this.provider === "opencode-zen") {
// OpenCode's anonymous Zen tier validates the CLI identity together with
// the public bearer credential. This does not apply to opencode-go, whose
// endpoint has no anonymous tier.
headers["Authorization"] = "Bearer public";
}

if (this._requestFormat === "claude") {
Expand All @@ -1002,11 +1012,9 @@ export class OpencodeExecutor extends BaseExecutor {
// Synthesize OpenCode CLI identity headers by default so Cloudflare in front of
// opencode.ai/zen doesn't 429 VPS requests lacking CLI identity. Opt-out via
// 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() ?? ""
);
// User-Agent is replaced unless the client already sends a valid versioned
// OpenCode identity. Default values match the upstream free-tier contract.
const synthesizeCli = isOpencodeCliSynthesisEnabled();
const cliDefaults = synthesizeCli
? (() => {
const providerId = this.config?.id || this.provider || "opencode";
Expand All @@ -1015,7 +1023,7 @@ export class OpencodeExecutor extends BaseExecutor {
userAgent:
process.env[envUAKey]?.trim() ||
process.env.OPENCODE_USER_AGENT?.trim() ||
"opencode",
getDefaultOpencodeUserAgent(),
client: process.env.OPENCODE_CLIENT?.trim() || "desktop",
project: process.env.OPENCODE_PROJECT?.trim() || "global",
};
Expand All @@ -1034,6 +1042,9 @@ export class OpencodeExecutor extends BaseExecutor {
messages: Array.isArray(b.messages)
? (b.messages as Array<{ role?: string; content?: unknown }>)
: undefined,
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 @@ -1042,18 +1053,6 @@ 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();
}

void model;

return headers;
Expand Down
91 changes: 91 additions & 0 deletions open-sse/utils/opencodeCliVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Live OpenCode CLI version for the synthesized `User-Agent` on Zen requests.
*
* The identity contract is pinned by the working OMP extension
* (`zen-patch.ts`): `opencode/<semver> ai-sdk/provider-utils/<semver>
* runtime/bun/<semver>`. Upstream validates this CLI identity on the free
* tier; a bare `opencode` or a stale pin rots on every release, so the
* `<semver>` resolves live from the npm registry JSON API
* (`opencode-ai/latest`) — plain HTTPS fetch, NO npm binary needed, so
* machines without npm work fine. Offline or registry blocked → the pin below
* stays valid and `OPENCODE_USER_AGENT` still overrides everything.
*
* Sync header synthesis (`getCachedOpencodeCliVersion`) is pure and never
* networks — cold/offline returns the pin. `OpencodeExecutor.execute()` kicks
* `refreshOpencodeCliVersion()` fire-and-forget per request (coalesced, 6h TTL,
* never throws), so the first request warms the cache in background and later
* requests send the live version.
*/

const NPM_OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest";

/** Cold/offline fallback. Bump when the pin visibly lags the registry. */
export const OPENCODE_CLI_VERSION_FALLBACK = "1.18.31";

export const OPENCODE_CLI_VERSION_TTL_MS = 6 * 60 * 60 * 1000;

const FETCH_TIMEOUT_MS = 5_000;

let cachedVersion: string | null = null;
let cachedAt = 0;
let inFlight: Promise<string> | null = null;

export function isOpencodeCliVersion(value: unknown): value is string {
return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value.trim());
}

/** Sync hot-path for header synthesis: pure, never networks. Cold/offline → pin. */
export function getCachedOpencodeCliVersion(): string {
if (cachedVersion && Date.now() - cachedAt < OPENCODE_CLI_VERSION_TTL_MS) return cachedVersion;
return cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK;
}

/**
* Warm/refresh the cache (coalesced across callers, never rejects).
* Fresh cache → returns it without fetching. Stale/absent → one registry
* lookup; failure keeps the stale cache or pin.
*/
export function refreshOpencodeCliVersion(): Promise<string> {
if (cachedVersion && Date.now() - cachedAt < OPENCODE_CLI_VERSION_TTL_MS) {
return Promise.resolve(cachedVersion);
}
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const response = await globalThis.fetch(NPM_OPENCODE_LATEST_URL, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const payload = response.ok ? ((await response.json()) as { version?: unknown }) : null;
const version = typeof payload?.version === "string" ? payload.version.trim() : "";
if (isOpencodeCliVersion(version)) {
cachedVersion = version;
cachedAt = Date.now();
}
} catch {
// Registry unreachable — stale cache / pin stay valid.
}
return cachedVersion ?? OPENCODE_CLI_VERSION_FALLBACK;
})();
const current = inFlight;
void current.finally(() => {
if (inFlight === current) inFlight = null;
});
return current;
}

/** Test seam: clear the cache. */
export function resetOpencodeCliVersionCache(): void {
cachedVersion = null;
cachedAt = 0;
inFlight = null;
}

/** Test seam: seed a fixed version (stale when cachedAt is old). */
export function configureOpencodeCliVersionForTests(version: string, cachedAtValue = Date.now()): void {
if (!isOpencodeCliVersion(version)) {
throw new TypeError(`Invalid OpenCode CLI version: ${version}`);
}
cachedVersion = version.trim();
cachedAt = cachedAtValue;
}
Loading