A local OpenAI-compatible proxy that relays chat requests to the OpenCode Zen free tier. Point any OpenAI-compatible agent (OpenCode, rikkahub, etc.) at it and use the free models with no API key.
Agent ──► http://127.0.0.1:8791/v1 ──► OpenCode Zen free tier (opencode.ai/inference/openai/v1)
Run it with Bun, not Node. The free tier fingerprints the outbound TLS client hello; Node/OpenSSL is answered with
429 FreeUsageLimitErroreven on an IP that still has free quota. See Two gates, not one.
opencode.ai/inference/openai/v1 enforces a client-fingerprint gate: a bare request is
rejected with 403 FreeTierError: OpenCode's free tier can only be used from within OpenCode. The upstream only accepts a request that carries all of:
| Requirement | Value |
|---|---|
User-Agent |
opencode/1.18+ (lowercase, version ≥ 1.18; older → 426) |
x-opencode-session |
a session id of the exact shape opencode emits (ses_ + 12 lowercase hex + 14 base62 chars — see below) |
body stream |
must be true (false → 403) |
body tools |
must contain all four of bash/glob/grep/read (missing any → 403) |
Authorization |
Bearer public (not validated server-side, but sent for parity) |
The session id is opencode's ordinary descending identifier
(packages/schema/src/identifier.ts):
ses_ + 12 lowercase hex + 14 base62 chars (30 chars total)
where the hex field is ~(Date.now() * 0x1000 + counter) truncated to 48 bits,
and the 14-char tail is random base62. Live ablation against the real endpoint
pinned exactly what the server validates:
- the low 12 bits of the hex field encode the counter as
0xfff − counter, and the gate requirescounter ≤ 63(counter0–63 → 200, 64+ → 403); - the first 12 chars must be lowercase hex (uppercase or non-hex → 403);
- the tail must be exactly 14 chars (13/15 → 403), any base62 charset;
- the timestamp magnitude/recency is not checked (1970 and 2035 both pass);
x-opencode-session-idmust equalx-opencode-sessionwhen present (mismatch → 403).
src/fingerprint.js reproduces this algorithm with Date.now() and a small
counter, so no opencode CLI, no MITM proxy, and no openssl are required —
the fingerprint is fully computed in pure JS on every request.
# Override the advertised User-Agent (default: opencode/latest/2.0.22/cli):
OPENCODE_UA=opencode/2.0.27 bun server.jsThe free tier has two independent gates, and they fail with different errors. Knowing which one you hit is the whole diagnosis:
| Gate | What it checks | Failure |
|---|---|---|
| Client fingerprint | User-Agent + x-opencode-session id shape + stream:true + the four tools |
403 FreeTierError |
| Runtime fingerprint | the outbound TLS client hello (Bun — the stack OpenCode ships) and OpenCode's <env> block in the system prompt |
429 FreeUsageLimitError |
The 429 is not a plain rate limit and the message ("Free usage limit reached…") is misleading. Ablation against the live endpoint pinned it down:
- the byte-identical request that returns
200from Bun returns429when it is replayed through a Node TLS tunnel (verified by terminating TLS in a local proxy: real OpenCode client → tunnel with Bun TLS ends up 200, the same request sent with Node'sfetch→ 429); - Node's TLS knobs (
ciphers,ecdhCurve,sigalgs, ALPN, TLS 1.2 vs 1.3) do not change the verdict — it is the client-hello fingerprint as a whole; - removing OpenCode's
<env>block (or truncating it) flips a Bun request back to429, while appending the block to an arbitrary system prompt flips it to200; - the same 429 appears from other egress IPs, so it is not an IP quota.
Hence: relay with Bun (the proxy does, and the Docker image enforces it) and
inject the <env> block into every upstream body (injectOpencodeSystemEnv
in src/conversions.js, applied in finalizeBody so follow-up turns carry it
too). The agent's own system prompt is preserved — the block is appended, never
replaces instructions.
The relay binds a public port, so by default anyone who knows the URL can
spend the free-tier quota of that egress IP. API_KEY closes that hole: it is
the client→relay credential (separate from the relay→upstream one, which is
still Bearer public / OPENCODE_ZEN_API_KEY).
docker run -d --name opencode-free-provider -p 48391:8791 \
-e API_KEY='sk-ofp-<your-secret>' \
ghcr.nju.edu.cn/jcleng/opencode-free-provider:latestClients then configure exactly that value:
Semantics:
API_KEY |
Behaviour |
|---|---|
| unset / empty | client auth disabled (backwards compatible) |
public (image default) |
the historical apiKey: public keeps working — convenient, not a secret |
| any other value | requests without it get 401 {"code":"invalid_api_key"} |
Accepted presentations: Authorization: Bearer <API_KEY> (what OpenAI clients
send) and x-api-key: <API_KEY>. / and /health stay public; /v1/* and
the /__* debug endpoints require the key (they expose the generated session
id). GET /__session reports auth.required.
curl -s http://127.0.0.1:8791/v1/models # 401 when API_KEY is set
curl -s -H 'Authorization: Bearer sk-ofp-…' http://127.0.0.1:8791/v1/models # 200npm start # = bun server.js, listens on http://127.0.0.1:8791/v1
# or
bun server.jsnode server.js still starts (the server is plain node:http), but every chat
request will be answered with 429 FreeUsageLimitError — the Node TLS stack is
rejected by the runtime gate. The process logs a warning in that case.
Env overrides:
| Variable | Default | Notes |
|---|---|---|
PORT |
8791 |
Listen port |
HOST |
127.0.0.1 |
Bind host |
OPENCODE_BASE_OVERRIDE |
https://opencode.ai/inference/openai/v1 |
Point the relay elsewhere (used by tests) |
ZEN_SESSION_MODE |
per-request |
per-request (fresh ses_ each call, safest) or sticky (reuse one for the process) |
ZEN_SESSION_ROTATE_SECONDS |
0 |
When sticky, rotate the session every N seconds |
ZEN_MODELS_TTL_SECONDS |
600 |
How long (seconds) the discovered model list is cached before re-fetching from upstream (0 = never expire) |
ZEN_MODELS_TIMEOUT_MS |
5000 |
Timeout for the upstream GET /models discovery request |
OPENCODE_UA |
opencode/latest/2.0.22/cli |
User-Agent advertised to the free-tier gate |
OPENCODE_ENV_CWD |
/ |
Working directory advertised in the injected <env> block (the caller's x-opencode-directory header wins when present) |
OPENCODE_SYSTEM_FP |
(unset) | Set to 0 to disable the <env> block injection (expect 429 FreeUsageLimitError from the free tier) |
API_KEY |
public (Dockerfile) |
Client→relay credential. When set, every /v1 request must send it; agents use the same value as their provider apiKey. Empty value disables client auth. See Client auth |
https_proxy / HTTPS_PROXY |
(unset) | Outbound HTTPS proxy for reaching opencode.ai. Requires NODE_USE_ENV_PROXY=1 (see below) |
NODE_USE_ENV_PROXY |
(unset) | Set to 1 to make the built-in fetch honor https_proxy/HTTPS_PROXY |
Debug endpoint (local only, never forwarded upstream):
curl http://127.0.0.1:8791/__session
curl http://127.0.0.1:8791/__models # model-registry cache status (?refresh=1 forces re-fetch)npm start dies with your shell. To run a copy from a fixed install directory
and keep it in sync with this repo:
npm run deploy # syncs repo -> ~/.local/share/opencode-free-provider, restarts, health-checks
OFP_INSTALL_DIR=/other/path npm run deployRun npm run deploy after every git pull — an install copy predating
src/fingerprint.js silently relays without a valid free-tier fingerprint and
every request fails upstream with 403 FreeTierError.
The image is based on oven/bun (Debian) and runs bun server.js. The
runtime matters: the free tier fingerprints the outbound TLS client hello and
Node/OpenSSL is rejected with 429 FreeUsageLimitError (see
Two gates, not one). Both fingerprints (headers/session id
and the <env> block) are generated in pure JS (src/fingerprint.js,
src/conversions.js), so no opencode CLI, no openssl, and no MITM are needed
inside the container.
# Build:
docker build -t opencode-free-provider .
# Run (binds 0.0.0.0:8791 inside the container by default):
docker run -d --name opencode-free-provider -p 8791:8791 opencode-free-providerOnce up, point your agent at http://<host>:8791/v1 (apiKey public). Verify
fingerprint generation:
curl http://127.0.0.1:8791/__session # expect runtime == "bun x.y.z" and fingerprint.source == "generated"The relay itself is a plain HTTP server on 127.0.0.1:8791; the outbound
request it makes to https://opencode.ai/inference/openai/v1 uses the runtime's
global fetch (Bun's when run with bun server.js). Bun's fetch honors
https_proxy/HTTPS_PROXY on its own; under Node, fetch (undici) does not
read them unless you opt in (see below).
To send the upstream traffic through an HTTP proxy (e.g. http://127.0.0.1:20171):
export https_proxy=http://127.0.0.1:20171
export HTTPS_PROXY=http://127.0.0.1:20171 # case-insensitive; both are read
export NODE_USE_ENV_PROXY=1 # REQUIRED under Node: makes global fetch honor the proxy
bun server.js # Bun needs no extra flagNODE_USE_ENV_PROXY=1 is the critical flag — without it, merely exporting
https_proxy has no effect and the relay connects to opencode.ai
directly. (Verified on Node ≥ 24: fetch only consults https_proxy/HTTPS_PROXY
when this flag is set, and emits a CONNECT tunnel to the proxy.)
Requires Node ≥ 24.
NODE_USE_ENV_PROXYwas added in Node v24.0.0. On Node 18/20/22 the flag is silently ignored, so the relay keeps connecting toopencode.aidirectly even thoughhttps_proxyis set and the container env looks correct. The bundledDockerfiletherefore builds onnode:24-bookworm-slim(a glibc/Debian base). To check a running container:docker exec opencode-free-provider node -v # expect v24.x or newerQuick litmus test — point
https_proxyat a dead port; with the flag on the fetch must fail withECONNREFUSED. If it succeeds, the proxy is being bypassed (wrong Node version, or the env vars never reached the process):NODE_USE_ENV_PROXY=1 https_proxy=http://127.0.0.1:39999 node -e \ 'fetch("https://example.com").then(r=>console.log("DIRECT",r.status)).catch(e=>console.log("PROXIED ->",e.cause?.code))'
Note: the proxy at
127.0.0.1:20171must support theCONNECTtunnel method for HTTPS upstreams. If you prefer to pin the proxy in code (independent of theNODE_USE_ENV_PROXYexperimental switch and Node version), configure an undiciProxyAgentviasetGlobalDispatcherinsrc/upstream.jsand pass it as thedispatcheron eachfetch.
baseURL: http://127.0.0.1:8791/v1
apiKey: public (any non-empty string works)
model: opencode-free/big-pickle
OpenCode (~/.config/opencode/opencode.json) example:
{
"provider": {
"opencode-free": {
"npm": "@opencode-ai/opencode",
"options": {
"baseURL": "http://127.0.0.1:8791/v1",
"apiKey": "public"
},
"models": [{ "id": "opencode-free/big-pickle", "name": "Big Pickle (Free)" }]
}
}
}PI Agent example:
"opencode-zen": {
"baseUrl": "http://127.0.0.1:8791/v1",
"api": "openai-completions",
"apiKey": "public",
"models": [
{ "id": "big-pickle" }
]
}GET /v1/models— OpenAI-style model listGET /v1/models/:id— single modelPOST /v1/chat/completions— streaming (stream:true) and non-streaming (stream:false, aggregated from upstream SSE by this proxy)GET /health,/— health textGET /__session— local fingerprint/session status (includesfingerprint.source:generated= computed in pure JS)GET /__models— local model-registry cache status (?refresh=1forces a re-fetch)
The model list is discovered live: on startup (and whenever the cache is
stale) the proxy calls
curl https://opencode.ai/inference/openai/v1/models (via GET {OPENCODE_BASE}/models in
src/config.js) and keeps only the ids whose id contains big-pickle or
contains -free — everything else (claude/gpt/gemini/… ids on the upstream list)
is filtered out. The result is cached for ZEN_MODELS_TTL_SECONDS (default 600s);
on a fetch failure the last good list is kept (before the first success the
built-in fallback is big-pickle).
npm test # 72 unit tests (mock upstream)Live end-to-end against the real free tier is exercised manually with the fingerprint described above.
Tool calls /
Tool not found: the upstream free tier requires the four tool namesbash/glob/grep/readin the body, but a duplicate name triggers403. The proxy (mergeToolsinsrc/conversions.js) keeps the agent's own tools and injects each required name only when missing, so the agent's realbash/glob/grep/read(with theircommand/path/patternschemas) are preserved — the model emits tool calls the agent can actually execute.Some OpenAI-compatible clients (e.g. pi-agent / pi-coding-agent) do not register every fingerprint tool — pi-agent has
bash/grep/read/ls/find/edit/writebut noglob. Because the gate forces all four names to be advertised, the model may call one the client cannot resolve, and the client would reportTool not found. The proxy is a pure forwarder + translator: it never executes tools itself (nonode:child_process, nonode:fs, no server-side cwd//app). It keeps all four names on the wire (the gate requires them) and, whenever the model emits a fingerprint tool the caller did NOT register, rewrites that tool_call into a tool the agent DOES implement — viatranslateFingerprintCall(src/conversions.js), driven byrunTranslateLoop(src/upstream.js). For example, aglobcall the agent lacks becomes abashcall with a globstar loop (shopt -s globstar nullglob; for f in <pattern>; do echo "$f"; done); the agent then executes it in its own working directory and returns the listing as ordinary text. If no registered tool can stand in for a fingerprint call (e.g. the agent has neitherbashnorfindforglob), the proxy relays a textual note back to upstream itself (a pure relay round-trip, still no execution) and lets the model continue. The agent never receives a tool_call name it cannot resolve, so it can never reportTool not found. The agent's own registered tools (and any fingerprint tool it does register) are always forwarded verbatim; only the names the agent lacks are translated. The original↔translated mapping survives the stateless request boundary by encoding the original call into the tool_call id (tr:prefix); on the agent's follow-up,serializeMessagesrestores the original call and folds the agent's real result back into the conversation as natural language.No working-directory plumbing. The proxy receives no cwd — no
x-opencode-cwdheader, no bodycwd/workdir. Because it never runs tools, there is no server-side directory to get wrong; the agent's filesystem is authoritative.Best setup: register
glob(andgrep/readif you can) on the client itself. The agent then executes those tools in its own filesystem, so the proxy forwards them verbatim and performs no translation at all.
MIT