OpenAI-compatible local API bridge for OpenCode free-tier models. No account and no API key required.
It runs a local server on http://127.0.0.1:20129/v1 that any OpenAI-compatible client (9Router, Cursor, Continue, LiteLLM, Open WebUI, or the OpenAI SDK) can call directly.
- Bun TLS transport parity: The official OpenCode CLI ships as a compiled Bun binary, and upstream's free-tier gate inspects the TLS
ClientHellofingerprint (17 cipher suites and 12 TLS extensions in exact order). The bridge runs on Bun (bun server.js), blocks accidental Node.js startups unlessALLOW_NODE=1is set for offline tests, and shipstransport-fingerprint.jsto verify TLS parity againsttransport-baseline.json. - Full source & catalog derivation: All hardcoded model tables (
MODEL_MAP) and model-name substring heuristics have been removed. Client version, commit SHA, session ID recipe (ses_+ 12 hex + 14 base62), 7 gate headers, 13 tool plugins (parsed from upstream's Effect Schema AST in GitHub source, 12 active per model afterpatchvsedit/writeswap), inference endpoints (chatUrlandresponsesUrl), active free models, shorthand family aliases, and reasoning token limits are derived directly from OpenCode's update API, GitHub repository, andmodels.opencode.ai/api.json. - On-disk catalog cache for instant cold starts: Caches
models.opencode.ai/api.jsonat~/.opencode-free-bridge/cache/catalog.jsonand loads it at startup (OPENCODE_CACHE_DIR=offdisables disk caching). Shorthand aliases (mimo,longcat,beam), deprecated/paid model checks, and reasoning token budgets work on the very first request and when offline. - Multi-layer reactive self-healing with IP-cooldown protection: On
403 FreeTierError,426 UpgradeRequired, or404on a migrated endpoint path, the bridge refreshes its recipe and catalog, evicts stale session IDs, mints a fresh session ID, escalates to the full tool profile, and retries once transparently. If a full-shape retry still returns403 FreeTierError(indicating a per-IP upstream cooldown rather than a stale request shape), subsequent requests within the 30s cooldown window (GATE_RETRY_COOLDOWN_MS,GATE_REFRESH_COOLDOWN_MS) skip redundant GitHub fetches and duplicate retries so the bridge never amplifies an IP penalty. Exception: the gateway's explicit IP-level abuse block ("detected abusive traffic from this IP address" /Detect Abuse Traffic) is terminal — the bridge answers it verbatim withX-OpenCode-Gate: ip-abuseand performs no refresh, session re-mint, or retry, because no client-side change can clear a flag keyed to the egress network. - Gate anchors, so a shape bug can never masquerade as an IP block: Upstream returns one
403 FreeTierError— "OpenCode's free tier can only be used from within OpenCode" — for at least four unrelated causes: a refused egress IP, a request without a tool namedread, a User-Agent that is notopencode/<channel>/<semver>/<name>, and a request without a session header. Measured by replaying byte-identical requests from a refused egress and a clean one, varying one field at a time:read+ 11 fabricated tools →200, the realreadrenamed →403, a fabricated tool namedread→200. The bridge therefore pins those anchors (GATE_ANCHOR_TOOL_NAMES,GATE_ANCHOR_HEADER_NAMES) independently of what the derived recipe and tool profile contain, audits the outgoing request when a403arrives, and reportsX-OpenCode-Gate: shape-rejectedplus the missing anchors instead of blaming the network. A second, unambiguous message ("detected abusive traffic from this IP address" orDetect Abuse Traffic) identifies a network-level block and is answered immediately asip-abuse; only the ambiguous free-tier message goes through the shape audit at all. Upstream'sx-opencode-log-idis recorded and exposed at/health(identity.gate.lastRejection) for correlation. - Zero-dead-end universal installer:
install.shfinds or installs Bun (andunzipwhen missing on minimal Linux), stages and validates every file before touching a running bridge, retires legacyopencode-bridge.serviceunits, rewritesnodeunits tobun, configuressystemd(Linux) orlaunchd(macOS), and verifies/healthand the TLS fingerprint baseline before succeeding.
- Self-healing client identity: Derives the OpenCode CLI wire identity at the current release commit SHA and updates automatically when upstream tightens the gate.
- Live catalog discovery: Reads
models.opencode.ai/api.json(and/v1/models) to expose only active free-tier models (cost.input === 0, non-deprecated) and reject retired or paid model IDs locally with a clear error. - Dual-protocol support: Routes requests to
/inference/openai/v1/chat/completionsor/inference/openai/v1/responsesbased on catalog metadata and automatic protocol-mismatch detection. - Reasoning-aware token budgeting: Applies an additive token floor (
max(requested, 1024) + 4096) for reasoning models (determined from catalogreasoning: truemetadata) so short prompts do not return empty content withfinish_reason: "length". - Reasoning stream normalization: Emits thinking tokens under
reasoning_contentfor both streaming SSE and non-streaming JSON responses, and forwards client tool calls with automatic name de-collision (read->read_clientupstream, restored toreadon return). - Zero dependencies: Uses only built-in runtime modules (
http,crypto,fs,path,net,tls). Nonpm installstep. - Container deployment: Ships a
Dockerfileanddocker-compose.yaml(contributed in PR #1 by @rustam76, completed in v2.1.0). The image pinsoven/bun:1.4.3-slim— the Bun version is pinned because the free-tier gate inspects the TLSClientHello— runs as the non-rootbunuser, and publishes the port on loopback only.
Bun is required to talk to the live OpenCode gateway. Node.js 18+ can run the offline unit test suite, but upstream rejects Node.js requests with 403 FreeTierError because Node's OpenSSL TLS ClientHello differs from the compiled OpenCode CLI (which is built on Bun/BoringSSL). The installer installs Bun for you when it is missing.
Supported: Linux (systemd) and macOS (launchd). Windows is not supported; use WSL if you need it there.
Measured against a raw TCP listener and the live gateway from the same IP within one minute, using identical headers and payload:
| Property | OpenCode CLI v2.0.32 | Bridge on Bun | Bridge on Node.js |
|---|---|---|---|
| TLS cipher suites offered | 17 | 17 (identical) | 52 |
| TLS extensions offered | 12 | 12 (identical order) | 11 (different order) |
Default Accept-Encoding |
gzip, deflate, br, zstd |
identical | gzip, deflate |
| Live gateway result | 200 OK |
200 OK (6/6) |
403 FreeTierError (0/6) |
Starting the server with node server.js exits immediately with an explanation so a broken Node deployment cannot fail silently in the background:
bun server.js # required for live traffic
node server.js # exits with code 1 and instructions
ALLOW_NODE=1 node server.js # starts for offline tests and mocked upstreams onlyYou can verify that your local Bun binary matches the recorded OpenCode CLI TLS baseline at any time:
bun transport-fingerprint.js --check # exits 0 if ciphers and extensions match
bun transport-fingerprint.js # prints current TLS ClientHello JSON
bun transport-fingerprint.js --write-baseline # updates transport-baseline.jsoncurl -fsSL https://raw.githubusercontent.com/azrinaldi/opencode-free-bridge/main/install.sh | bashWhat install.sh does:
- Finds
bunacrossPATH,$BUN_INSTALL/bin,~/.bun/bin,~/.local/bin,/usr/local/bin, and/opt/bun/bin, or installs it viahttps://bun.sh/install(installingunzipfirst on minimal Linux images when needed). - Downloads every file into a staging directory and validates it (in-process JS syntax compilation, JSON parsing, and
bash -nfortunnel-doctor.sh) before touching anything that is already running. - Checks port availability (
ssorlsof), retires a pre-2.0opencode-bridge.servicethat would hold port 20129, and stops its own running bridge when upgrading in place. - Backs up the previous install under
~/.opencode-free-bridge/.backup-*, then moves the staged files into place. - Registers and starts the background service (
systemdsystem or--userunit on Linux,launchdagent with~/Library/Logs/opencode-free-bridge.logon macOS). - Waits up to 20 seconds for
/health, runs the quick self-check, and checks the Bun TLS fingerprint againsttransport-baseline.json.
If the bridge does not answer after 20 seconds, the installer exits with code 1 and prints the exact log inspection command. It only prints Done. when the bridge is answering.
Installer options:
PORT=3000 BINDIR=/opt/opencode-free-bridge bash install.sh
NO_SERVICE=1 bash install.sh # install files only, skip systemd/launchd setupAn occupied port aborts the install before anything is changed. To install alongside an existing service, choose a free port:
PORT=20130 bash install.shTo update an existing installation, re-run the curl ... | bash command above. Running instances outside a git checkout also check for new releases in this repository every 6 hours (AUTO_UPDATE=0 disables self-update).
git clone https://github.com/azrinaldi/opencode-free-bridge.git
cd opencode-free-bridge
bun server.jsThe server listens on http://127.0.0.1:20129 by default.
The repository ships a Dockerfile and a docker-compose.yaml for container
deployments (single host, no orchestrator needed):
git clone https://github.com/azrinaldi/opencode-free-bridge.git
cd opencode-free-bridge
docker compose up -dThe bridge is then reachable at http://127.0.0.1:20129/v1 (API key public),
exactly like a native install, and the model catalog cache survives restarts in
the bridge-cache volume.
What the container setup does:
- Runs on
oven/bun:1.4.3-slimas the non-rootbunuser. The Bun version is pinned because the free-tier gate inspects the TLS ClientHello: the image must produce the same fingerprint as the OpenCode CLI, whichtransport-baseline.jsonrecords. - Publishes the port on loopback only. The bridge has no authentication
(the API key is literally
public), so publishing on0.0.0.0would expose an open relay to the whole network. For remote access, put a reverse proxy or an SSH tunnel in front of it. - Persists the on-disk catalog cache in the
bridge-cachevolume (OPENCODE_CACHE_DIR=/data/cache), so a restart does not re-fetch the upstream model catalog. - Runs with
no-new-privilegesand all Linux capabilities dropped.
The image is the unit of deployment, so AUTO_UPDATE defaults to 0 in the
compose file: an in-place self-update is reverted by the next
docker compose build. Upgrade with:
docker compose build --pull && docker compose up -dPlain docker run works too — set HOST=0.0.0.0 and publish the port
yourself:
docker build -t opencode-free-bridge .
docker run -d --name opencode-free-bridge \
-p 127.0.0.1:20129:20129 \
-e OPENCODE_CACHE_DIR=/data/cache \
-v opencode-free-bridge-cache:/data/cache \
opencode-free-bridge| Setting | Value |
|---|---|
| Base URL | http://127.0.0.1:20129/v1 |
| API Key | public (any non-empty string is accepted) |
| Model ID | Any ID returned by GET http://127.0.0.1:20129/v1/models |
- Add a custom OpenAI-compatible provider:
- Base URL:
http://127.0.0.1:20129/v1 - API Key:
public - Prefix:
zen - Model IDs: leave empty and click Import from /models endpoint
- Base URL:
- Models are imported as
zen/big-pickle,zen/mimo-v2.6-flash-free,zen/nemotron-3-ultra-free, etc. - Optional: if you run the bridge on multiple hosts (for example, a laptop and a VPS), register each host as a separate connection in 9Router so per-IP rate limits fail over automatically.
Use the same three settings: Base URL http://127.0.0.1:20129/v1, API key public, and model IDs from /v1/models.
# LiteLLM config.yaml example
model_list:
- model_name: zen-mimo
litellm_params:
model: openai/mimo-v2.6-flash-free
api_base: http://127.0.0.1:20129/v1
api_key: publicexport OPENAI_BASE_URL="http://127.0.0.1:20129/v1"
export OPENAI_API_KEY="public"
export MODEL="mimo-v2.6-flash-free"from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:20129/v1", api_key="public")
response = client.chat.completions.create(
model="mimo-v2.6-flash-free",
messages=[{"role": "user", "content": "Write a Python palindrome checker."}],
)
print(response.choices[0].message.content)curl -s http://127.0.0.1:20129/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"mimo-v2.6-flash-free","messages":[{"role":"user","content":"Reply with OK"}]}'The model list is derived dynamically from models.opencode.ai/api.json (cost.input === 0 and status !== "deprecated") and cached at ~/.opencode-free-bridge/cache/catalog.json.
Current active free-tier models in the upstream catalog:
| Model ID | Family Shorthand | Reasoning | Max Output Tokens |
|---|---|---|---|
big-pickle |
big-pickle |
Yes | 32,000 |
beam-free |
beam |
Yes | 131,072 |
mimo-v2.6-flash-free |
mimo |
Yes | 32,000 |
nemotron-3-ultra-free |
nemotron-free |
Yes | 128,000 |
nemotron-3.5-lightning-free |
- | Yes | 262,144 |
nemotron-3.5-super-vl-free |
- | Yes | 131,072 |
longcat-2.5-preview-free |
longcat |
Yes | 131,072 |
deepseek-v4.1-flash-free |
deepseek-flash |
Yes | 32,000 |
glm-5.3-flash-free |
glm-flash |
Yes | 32,000 |
space-bunny-free |
- | Yes | 524,288 |
Query the live list from your running bridge:
curl -s http://127.0.0.1:20129/v1/modelsRequests for retired models (such as cat-jam-free, muse-spark-1.3-contributor-free, or step-5-preview-free) or paid models (such as claude-sonnet-4-5 or gpt-5.4) are rejected locally with HTTP 404 and an explanation rather than being forwarded upstream to fail with 401 Missing API key.
Reasoning models spend tokens on chain-of-thought before emitting visible text, and both share the same max_completion_tokens ceiling. When a client sends a small max_tokens (such as 50 or 100 for a health probe or title generator), a reasoning model can exhaust the entire budget while thinking and return an empty string with finish_reason: "length".
token-budget.js checks the model's reasoning flag from the catalog (identity.getModelFacts(modelId)) and applies an additive floor:
| Request Type | Effective max_completion_tokens Sent Upstream |
|---|---|
Catalog reasoning: true model (or request with reasoning_effort set) |
min(max(requested, 1024) + 4096, modelMaxOutput) |
| Non-reasoning model | min(max(requested, 512), modelMaxOutput) |
The client's requested value is never lowered (unless it exceeds the model's catalog limit.output or 131072).
Client ──▶ Bridge (:20129, Bun) ──▶ https://opencode.ai/inference/openai/v1/...
│
├─ 1. Handshake: opencode.ai/update/api/latest/cli/npm
│ -> resolves latest CLI version, minimum floor, and GitHub commit SHA
├─ 2. Source AST parser: raw.githubusercontent.com/anomalyco/opencode/<sha>/...
│ -> extracts session ID recipe, UA template, 7 gate headers,
│ 13 tool plugins (Effect Schema AST), and model-conditional tool swaps
├─ 3. Provider catalog: models.opencode.ai/api.json (cached on disk)
│ -> extracts inference endpoints, 10 free models, family aliases,
│ deprecated/paid verdicts, and per-model reasoning/output limits
└─ 4. On 403 / 426 / 404:
-> refreshes recipe + catalog, evicts stale session ID, mints fresh
ses_<12hex><14base62> ID, escalates to full tool profile, retries once
(throttled for 30s if full-shape retry is still refused by per-IP gate)
| Layer | Source | Fallback if unreachable |
|---|---|---|
| CLI version & minimum floor | opencode.ai/update/api/latest/cli/npm (fallback: GitHub releases.atom) |
DEFAULT_RECIPE.version (2.0.32) |
| Session ID charset, length, multiplier | packages/schema/src/identifier.ts |
62-char base62, length 26, mult 4096 |
| Session ID prefix | packages/schema/src/session-id.ts |
ses_ |
| User-Agent template | packages/core/src/app.ts |
opencode/${channel}/${version}/${cli} |
| Required & optional gate headers | packages/core/src/session/model-request.ts |
7 core OpenCode headers |
| Tool plugin list & wire allowlist | GitHub tree listing of packages/core/src/tool/plugin/*.ts + codemode/tool.ts |
13 known plugins (read, shell, edit, etc.) |
| Tool JSON schemas & descriptions | Effect Schema AST parser across plugin .ts, sibling .txt, and @opencode/schema/* |
DEFAULT_TOOLS (read, shell) |
Model-conditional tool swaps (edit/write vs patch) |
packages/core/src/tool/plugin/patch.ts |
gpt-* condition rule |
Inference endpoints (chatUrl, responsesUrl) |
models.opencode.ai/api.json (opencode.api, validated under *.opencode.ai) |
https://opencode.ai/inference/openai/v1/* |
| Free model list, paid/retired verdicts, family aliases, token limits | models.opencode.ai/api.json |
~/.opencode-free-bridge/cache/catalog.json |
curl -s http://127.0.0.1:20129/health{
"status": "ok",
"provider": "opencode-free-bridge",
"version": "2.0.0",
"runtime": "bun/1.4.2",
"discoveredModels": 10,
"identity": {
"userAgent": "opencode/latest/2.0.32/cli",
"updateMode": "reactive",
"sourceTag": "d4a6223d017e17f1beaa91d3e6ecc6511c5132ad",
"versionFloor": null,
"lastCheckAgeMs": 1240,
"ttlMs": 900000,
"lastError": null,
"release": {
"version": "2.0.32",
"sha": "d4a6223d017e17f1beaa91d3e6ecc6511c5132ad",
"minimum": false,
"active": true,
"channel": "latest",
"source": "update-api",
"resolvedAt": 1760740000000,
"stale": false
},
"available": null,
"headers": [
"x-opencode-session-id",
"x-session-affinity",
"X-Session-Id",
"User-Agent",
"x-opencode-project",
"x-opencode-session",
"x-opencode-client"
],
"unsentHeaders": [],
"endpoints": {
"chatUrl": "https://opencode.ai/inference/openai/v1/chat/completions",
"responsesUrl": "https://opencode.ai/inference/openai/v1/responses",
"modelsUrl": "https://opencode.ai/inference/v1/models",
"catalogBase": "https://opencode.ai/inference/openai/v1"
},
"toolNames": [
"read", "shell", "edit", "write", "glob", "grep", "patch",
"question", "skill", "subagent", "webfetch", "websearch", "execute"
],
"tools": {
"source": "source",
"version": "2.0.32",
"toolNames": [
"execute", "edit", "glob", "grep", "patch", "question",
"read", "shell", "skill", "subagent", "webfetch", "websearch", "write"
]
}
}
}bun test # 145 offline unit, contract, and regression tests
bun run verify # live check: probes every discovered free model
bun run verify:quick # live check: stops at the first model that answers
bun run drill # runs live + offline self-healing gate drills
bun run fingerprint:check # checks Bun TLS ClientHello against transport-baseline.jsonverify.js exits 0 when a model answers, 2 when the bridge is healthy and its TLS fingerprint matches the baseline but upstream refuses this IP (free-tier allowance or an explicit abuse block), and 1 for a real failure (bridge unreachable, running on Node.js, or TLS fingerprint drift). The 2 case is what a throttled or flagged network looks like: the official OpenCode CLI gets the same 403 FreeTierError from the same IP at the same moment, so it is not a reason to reinstall or edit headers. The bridge labels the refusal (X-OpenCode-Gate: ip-throttled | ip-abuse | shape-rejected), so verify.js no longer depends on upstream keeping identical wording.
gate-drill.js deliberately corrupts the bridge's state and verifies that a single client request triggers self-healing and still returns HTTP 200:
| Drill | Mode | What it corrupts / stages | What it verifies |
|---|---|---|---|
version-floor |
Live | User-Agent version below floor | 426 UpgradeRequired -> floor learned -> identity refreshed -> retry succeeds |
empty-tools |
Live | Empty tool profile | Falls back to shipped tool definitions instead of sending tools: [] |
session-shape |
Live | Unrecognized session ID prefix | 403 FreeTierError -> recipe refreshed -> old ID evicted -> fresh ses_... ID minted |
legacy-shape |
Live | Pre-v1.5 request shape | Control check against live upstream |
routing |
Offline | chatUrl repointed after boot |
Endpoints are resolved per request from recipe/catalog, not frozen at boot |
allowlist |
Offline | Upstream renames a tool (shell -> run) |
Extracted tool name reaches the wire in full scope and retired name is dropped |
retry-endpoint |
Offline | 403 gate rejection + catalog URL change |
The self-heal retry uses the newly refreshed endpoint URL |
endpoint-move |
Offline | 404 on retired endpoint path |
Re-reads catalog on 404 and retries at the new path transparently |
gate-heal |
Offline | Stale prefix + minimal tool scope | Full heal order: 403 -> refresh -> re-mint session ID -> escalate to full tools -> 200 |
full-corrupt |
Offline | Version 0.9.9, prefix old_, length 20, hex charset, default tools |
One client request heals all corrupted layers simultaneously from source and returns 200 |
Before running the 4 live drills, gate-drill.js sends a single CLI-shaped control probe. If the current IP is temporarily throttled by upstream (403 FreeTierError on the control probe), the 4 live drills report SKIP so they do not extend the IP cooldown, while the 6 offline drills still execute and determine the exit code.
| Environment Variable | Default | Description |
|---|---|---|
PORT |
20129 |
HTTP listen port |
HOST |
127.0.0.1 |
Bind address (keep on 127.0.0.1 or behind a firewall; the server has no auth) |
CORS_ORIGIN |
"" (localhost only) |
Allowed CORS origin(s), comma-separated or * |
UPSTREAM_TIMEOUT_MS |
120000 |
Upstream fetch timeout in milliseconds |
MAX_BODY_BYTES |
10485760 |
Maximum request body size in bytes (10 MiB) |
IDENTITY_TTL_MS |
900000 |
Identity freshness TTL in milliseconds (15 minutes) |
UPDATE_MODE |
reactive |
reactive adopts new CLI releases only on 403/426 or minimum: true; always adopts every release on TTL expiry |
GATE_RETRY_COOLDOWN_MS |
30000 |
Cooldown window (ms) after a failed full-shape 403 retry during which duplicate retries are suppressed until identity changes |
GATE_REFRESH_COOLDOWN_MS |
30000 |
Cooldown window (ms) between forced GitHub/catalog refreshes during a 403 burst |
DNS_RESULT_ORDER |
ipv4first |
DNS resolution order passed to dns.setDefaultResultOrder (ipv4first, ipv6first, verbatim) |
TOOL_PROFILE_SCOPE |
core |
core sends the minimal gate anchor (read, shell) on first attempt and escalates to full (all model-applicable tools) on retry; full sends the full tool profile on every request |
OPENCODE_CHANNEL |
latest |
OpenCode release channel (latest, beta, dev) |
OPENCODE_MAJOR |
(unset) | Pin a major version line (for example 2) |
OPENCODE_USER_AGENT |
(derived) | Override User-Agent and disable dynamic identity derivation |
OPENCODE_CATALOG_URL |
https://models.opencode.ai/api.json |
Provider catalog URL |
OPENCODE_CACHE_DIR |
~/.opencode-free-bridge/cache |
Directory for the on-disk catalog.json cache (off disables disk caching) |
OPENCODE_CHAT_URL |
(derived) | Override the upstream Chat Completions endpoint URL |
OPENCODE_RESPONSES_URL |
(derived) | Override the upstream Responses API endpoint URL |
UPSTREAM_RELAY_URL |
(unset) | Route inference through an egress relay (x-relay-target / x-relay-path contract); empty = direct. See Optional egress relay |
AUTO_UPDATE |
1 |
Poll GitHub for bridge updates every 6 hours (automatically skipped inside a .git checkout; set 0 to disable) |
ALLOW_NODE |
(unset) | Set ALLOW_NODE=1 to bypass the startup Bun check for offline tests |
Set UPSTREAM_RELAY_URL to a relay that speaks the same contract as 9Router's
Cloudflare/Vercel/Deno/Netlify relays. The bridge POSTs to the relay with
x-relay-target: <origin> and x-relay-path: <path>, and the relay replays the
request at that origin; upstream therefore sees the relay's egress IP.
UPSTREAM_RELAY_URL="https://relay.example.workers.dev" bun server.jsThe relay covers inference requests and their retries; model discovery and
identity synchronization stay direct. /health reports egress.mode (direct
or relay) and the relay's origin, without credentials or query strings. Use a
trusted HTTPS relay: it receives the request headers (including
Authorization: Bearer public) and the full prompt. Changing the variable
requires a restart.
This is not the shape gate and not a stale bridge. The gateway has flagged the
network's egress IP for abusive traffic; the message says it is unrelated to the
account. The bridge treats it as terminal: it does not refresh the identity,
re-mint the session id, or retry, and returns the refusal verbatim with
X-OpenCode-Gate: ip-abuse. /health reports identity.gate.status: "ip-blocked" and identity.gate.lastRejection.kind: "ip-abuse".
The official OpenCode CLI gets the same answer from the same network. Only
upstream can clear the flag (help@anoma.ly, with the time it started and the
x-opencode-log-id from the response). Until then, change egress (route through
another host or SSH tunnel), subscribe to OpenCode Go, or wait. Reinstalling the
bridge or editing headers will not help.
When the bridge receives 403 FreeTierError, it refreshes its recipe from OpenCode's update API and GitHub source tree, re-mints the session ID, escalates to the full tool profile, and retries once. If the message names an explicit IP-level abuse block (abusive traffic / Detect Abuse Traffic), this path is skipped entirely — see the section above.
If that retry also returns 403 FreeTierError and /health shows "lastError": null with the current CLI version (for example opencode/latest/2.0.32/cli), your current IP is in a temporary free-tier cooldown upstream. To prevent extending the penalty, the bridge suppresses duplicate 403 retries and forced GitHub refreshes for 30s (GATE_RETRY_COOLDOWN_MS). Running the official OpenCode CLI binary (opencode run -m opencode/big-pickle "hi") from the same IP at the same moment returns the exact same 403 FreeTierError. Wait for the per-IP window to reset or route through another host/SSH tunnel.
Upstream is throttling the specific model or current IP due to capacity. Try another model ID from /v1/models.
The model was marked status: "deprecated" in models.opencode.ai/api.json (for example cat-jam-free, muse-spark-1.3-contributor-free, or step-5-preview-free). Query curl -s http://127.0.0.1:20129/v1/models and switch your client to one of the active models.
The service runs fine, but curl https://bun.sh/install | bash only adds
~/.bun/bin to an interactive shell's PATH. A new non-login SSH session, or a
sh -c command, does not see it:
export PATH="$HOME/.bun/bin:$PATH" # or use the absolute path
/root/.bun/bin/bun transport-fingerprint.js --checkThe installer prints the absolute path in its closing summary for this reason. The service itself is unaffected: its unit file records the full path.
The receiving host still has a stale listener from a tunnel whose sender rebooted or lost power. The TCP connection died without a FIN, so sshd keeps the forwarded port until keepalive notices — ten minutes with the default ClientAliveInterval 300 × ClientAliveCountMax 2. The replacement tunnel then fails even though its own unit is healthy.
./tunnel-doctor.sh # which forwards are stale (listener with no SSH transport)
./tunnel-doctor.sh --reap # kill only the stale sessions
./tunnel-doctor.sh --check # is the keepalive window short enough?To close the window itself, on the receiving host:
# /etc/ssh/sshd_config.d/00-keepalive.conf — the 00- prefix matters
ClientAliveInterval 30
ClientAliveCountMax 4 # dead peer detected in 120sOpenSSH uses the first value it reads, so a later-sorting file (for example 99-*.conf) is silently ignored. Verify with sshd -T | grep -i clientalive and systemctl reload ssh.
# Linux (systemd user or system service)
journalctl --user -u opencode-free-bridge -n 50 --no-pager
journalctl -u opencode-free-bridge -n 50 --no-pager
# macOS (launchd)
tail -50 ~/Library/Logs/opencode-free-bridge.log
# Manual foreground run
bun ~/.opencode-free-bridge/server.jsMIT