- Rust 76.2%
- HTML 14.6%
- Nix 5.9%
- Shell 3.3%
| assets | ||
| nix | ||
| scripts | ||
| src | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
oc — free opencode.ai models over OpenAI API
No account needed. Accepts requests like LM Studio / any OpenAI client,
impersonates opencode and talks to https://opencode.ai/inference/v1.
The per-model jailbreak specs from assets/jailbreaks/ are embedded in the
binary and appended to the system prompt (file name = model id, Unified.md
fallback).
Install
bash -c "$(curl -fsSL https://git.rootshell.space/kudokushi/oc/raw/branch/main/scripts/install.sh)" _ --host 127.0.0.1 --port 1234 --web-ui
Linux + systemd: installs /var/lib/oc/oc, starts oc.service on 127.0.0.1:1234 and self-updates; --help for all flags.
Run
./target/release/oc
# or
PORT=1234 HOST=127.0.0.1 ./target/release/oc
# version
./target/release/oc --version
Environment variables:
| var | default | meaning |
|---|---|---|
PORT |
1234 |
port (same as LM Studio) |
HOST |
127.0.0.1 |
bind address |
ZEN_BASE |
https://opencode.ai/inference/v1 |
upstream (/zen/v1 no longer serves the free tier) |
OPENCODE_API_KEY |
public |
key; public without an account. Secrets better via OPENCODE_API_KEY_FILE (file wins) |
OPENCODE_UA |
captured opencode/... |
User-Agent sent upstream (mimicry) |
AUTH_TOKEN |
— | client token; set, everything under /v1 needs Authorization: Bearer <token>. Secrets better via AUTH_TOKEN_FILE (wins; one key per line, # comments — several keys allowed) |
WEB_UI |
off | serve the built-in chat page at /chat (1/on enables) |
PROXIES |
— | outbound proxy pool: socks5h://user:pass@host:port, http://... separated by ,/;/newline |
PROXIES_FILE |
— | file with proxy list (one URL per line, # comments), merged with PROXIES |
PROXY_FAILOVER_AFTER |
3 |
consecutive retryable failures before failing over to proxies; without errors, always direct |
PROXY_COOLDOWN_SECS |
180 |
how long to stay on proxies once failed over (proxy successes extend the window, direct success ends it) |
MAX_UPSTREAM_ATTEMPTS |
3 |
sends per logical turn, skipping cooling-down routes with backoff; only transport errors and 429/5xx are retried |
RETRY_DELAY_MS |
800 |
base delay between upstream attempts (exponential: base, 2x, 4x, ...) |
RATE_LIMIT_COOLDOWN_SECS |
120 |
base per-route block after a 429, doubled per repeat (Retry-After wins when present) |
RATE_LIMIT_MAX_SECS |
43200 |
cap for the grown 429 block |
ROUTE_ERROR_COOLDOWN_SECS |
30 |
per-route block after a transport error or 5xx |
PROBE_MODELS |
first 3 live free | models for egress probes (limits are per model and IP) |
USE_DIRECT |
1 |
include a direct outbound (0 = proxies only) |
EGRESS_ORDER |
sticky |
sticky prefers direct, spread round-robins healthy routes |
MIN_OUTPUT_TOKENS |
1024 |
floor for client output caps, tiny caps always return incomplete (0 = off) |
MODELS_CACHE_SECS |
300 |
catalog refresh interval (0 = refetch on every /v1/models) |
MODELS_DEV_URL |
https://models.opencode.ai/api.json |
catalog URL |
DEFAULT_MODEL |
deepseek-v4.1-flash-free, else first live free |
model when the client doesn't specify one |
JAILBREAKS |
on | embedded per-model jailbreak specs appended to the system prompt (0/off/false disables) |
MAX_HISTORY_CHARS |
auto | history cap in chars; unset = computed per model (see below) |
MAX_HISTORY_MESSAGES |
500 |
history cap in messages (0 = unlimited) |
MAX_TOOL_ROUNDS |
3 |
tool-loop follow-ups per request (0 = single attempt, still sanitized) |
TOOL_UNAVAILABLE_MSG |
— | custom text fed back as every tool result (default: no workspace access, answer directly) |
FORWARD_CLIENT_TOOLS |
0 |
append client tools upstream; pure client-tool rounds are returned to the client for execution |
LOG_LEVEL |
off |
trace verbosity: off/0, basic/1, full/2, trace/3 |
LOG_MAX_CHARS |
2000 |
preview cap per dump (0 = unlimited; trace is always unlimited) |
UPDATE_MANIFEST |
repo manifest | override for the self-update manifest URL |
RUST_LOG |
— | verbose logs, e.g. RUST_LOG=oc=debug (session hit/miss visible) |
Log levels
For "why did it answer strangely" investigations — every client request,
upstream request, turn result and tool call is logged at info level
(visible without touching RUST_LOG):
LOG_LEVEL=basic— one line per client request (model, stream, session, message roles/sizes), one line per upstream turn (route,ses_/msg_, latency, model/sections/caps), one line per turn result (finish reason, SSE bytes, text preview,usage, tool names+args), plus history truncation notices. System prompt is shown as[system Nch], never dumped.LOG_LEVEL=full— everything frombasic, plus full client JSON, upstream JSON (system redacted to length) and raw upstream SSE, all capped byLOG_MAX_CHARS(dumps usemax*10).LOG_LEVEL=trace— wire mode: full untruncated JSON and SSE, including the ~20KB system prompt on every turn.
LOG_LEVEL=basic ./target/release/oc
LOG_LEVEL=full LOG_MAX_CHARS=5000 ./target/release/oc
LOG_LEVEL=trace ./target/release/oc 2>&1 | tee strange-dialog.log
NixOS: services.oc-zen-proxy.logLevel = "full"; (+ logMaxChars).
Secrets are never logged (no Authorization header, no proxy passwords).
Connect LM Studio
Base URL: http://127.0.0.1:1234/v1, models are discovered automatically.
The list comes from the models.dev catalog (models.opencode.ai/api.json,
the same file opencode uses): every non-deprecated *-free model, refreshed
in the background every MODELS_CACHE_SECS. If the catalog is unreachable,
the last good list is kept; with no list at all, deepseek-v4.1-flash-free
is served as a last resort. DEFAULT_MODEL pins the default; unset, the
built-in pick deepseek-v4.1-flash-free wins when it is live, otherwise the
first live free model from the list.
To pin a dialog to a fixed upstream session, send a custom header
x-opencode-session: ses_... (LM Studio supports custom headers). The value
must be a well-formed id (ses_ + 12 hex + 14 base62); anything else is
ignored, since upstream answers 403 to malformed ids.
Check:
curl http://127.0.0.1:1234/v1/models
curl http://127.0.0.1:1234/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"deepseek-v4.1-flash-free","messages":[{"role":"user","content":"What is 2+2?"}]}'
Web chat
Off by default: start with WEB_UI=1 (module: webUi = true) to serve the
built-in page at http://127.0.0.1:1234/chat. Streaming, model picker,
markdown/code rendering, one transcript per tab — no chat history and no
separate reasoning view on purpose. curl http://127.0.0.1:1234/ still
returns the art.
The token field appears only when a token is configured and a request came
back 401; the token is kept in localStorage. The page and the art stay
reachable without a token, /v1/* does not.
Which URL goes where
The proxy serves the whole namespace on one port (PORT, default 1234):
| what | where to put it | example |
|---|---|---|
| Base URL for OpenAI clients (LM Studio, Cline, Continue, Open WebUI, aichat) | Base URL / API Base field | http://127.0.0.1:1234/v1 |
Full chat URL (tgpt --url, curl) |
— | http://127.0.0.1:1234/v1/chat/completions |
| Model list | — | http://127.0.0.1:1234/v1/models |
| Built-in web chat | browser | http://127.0.0.1:1234/chat |
| Behind nginx on a server | proxyPass |
http://127.0.0.1:1234/ (root to root, clients append /v1/... themselves) |
Note: Base URL always ends with /v1, while nginx proxyPass has
no suffix (root to root, see the NixOS section). An empty client key is
fine, the proxy ignores it: upstream always gets Bearer public or
OPENCODE_API_KEY — unless client tokens are configured (AUTH_TOKEN /
AUTH_TOKEN_FILE), then the client key must be one of them.
How the mimicry works
The free tier (Bearer public) answers 403 FreeTierError to anyone
not looking like opencode. The proxy signs every request as opencode run:
Authorization: Bearer public,User-Agent: opencode/...x-opencode-session: ses_.../x-opencode-session-id: ses_...(same value) /x-opencode-request: msg_...— valid IDs perpackages/schema/src/identifier.ts(first 12 hex =timestamp_ms * 0x1000 + counter, then 14 base62); random IDs are rejected by timestamp check, and a missingx-opencode-session-idanswers403 FreeTierError.msg_is fresh per request,ses_is sticky per dialog (see below)stream: trueupstream always (withstream:falseupstream answers403, so for non-streaming clients SSE is reassembled back into onechat.completion)- the body is wrapped in the exact
opencode runtemplate:assets/zen/main_system.txt(~20KB system) +assets/zen/tools.json(11 built-in tools,tool_choice:auto) + client messages. Missing template tools or a foreign system —403(verified live); extra tools appended to the template are tolerated (also verified live). Client tools are appended upstream only withFORWARD_CLIENT_TOOLS=1, otherwise ignored. Upstreamtool_callsreach the client only in forward mode, see "Tool-call loop" below.
Assets were captured by intercepting a real opencode run
(OPENCODE_MODELS_PATH -> local logger). The model identity line in
main_system.txt is the one edit: it holds a {model} placeholder that
the proxy substitutes per request (a re-captured name is substituted too).
Per-model format routing
Zen serves different models in different wire formats (wrong format =
generic 500 Internal server error — that's what aichat hit on muse-spark).
The proxy routes itself, the client always speaks OpenAI chat. The format is
derived from the catalog's provider.npm, not from a hardcoded list:
catalog npm |
upstream | translation |
|---|---|---|
| none (OpenAI-compatible) | POST /inference/v1/chat/completions |
SSE as is |
@ai-sdk/openai |
POST /inference/v1/responses |
Responses SSE → chat SSE (text, reasoning, function calls, usage) |
@ai-sdk/anthropic |
— | not implemented; a clear 400 is returned |
Unknown models default to chat/completions. Today's live free models are
all OpenAI-compatible (chat); the deprecated muse-spark-* family routed
to responses, and the deprecated minimax-*/qwen3.6-plus-free were
anthropic-format and are gone.
History caps (dynamic)
No worst-case-for-all limits. The proxy fetches
models.opencode.ai/api.json (same catalog opencode uses) at startup and
refreshes it every MODELS_CACHE_SECS, taking each model's limit.context
(kept as the last good copy when unreachable → minimal 200k assumed until
the first successful fetch). Per request:
history = context − template − response_reserve − 10% margin
where template is measured from our own assets (~11k tokens),
response_reserve is the request's max_tokens (or 32k default).
Result in chars (×4), floored at 20k. So a large-window model gets hundreds
of thousands of tokens of history while a 200k-window one gets ~150k. Explicit
MAX_HISTORY_CHARS wins over the computed value.
Trimming keeps the newest messages (oldest dropped first) and never
leaves a dangling head (a tool result without its call goes too).
It runs before the template is prepended, so system+tools stay intact.
Assistant turns with neither text nor tool calls (aborted/errored client
turns) are dropped before that: replayed they make strict upstreams answer
400 ... must not be empty and poison the client's session.
Second layer is reactive: on overflow-ish upstream errors (400/413
mentioning context/length) the proxy halves the cap and retries in the
same session (prefix cache stays warm), up to 2 retries.
Sticky sessions (cache)
Upstream keeps a prefix cache (system+tools) and sticky provider routing
keyed by x-opencode-session, like real opencode. The OpenAI protocol
has no sessions, so the proxy derives them: hash of (model + history) —
a retry hits the full hash, the next dialog turn hits the history hash
minus the last 1-3 messages (clients usually append
[assistant reply + user]). Forks/edits/new chats transparently get
a fresh session. Cache: 512 dialogs, 30 min TTL. Verified in logs:
the second turn reuses the same ses_ (session hit, depth=2).
Tool-call loop
Upstream answers with opencode tool calls (read, bash, …) that the
client never asked for and cannot run. Forwarding them as-is kills plain
chats (empty reply / error), so the proxy loops instead: every call is
answered with "tool unavailable, answer directly" and the request is
resent (same session, fresh msg_), until clean text arrives.
usage is summed across rounds. If the model keeps calling tools past
MAX_TOOL_ROUNDS, the client gets the accumulated text, or a short note
if there is none — opencode tool_calls never leak to the client.
websearch and webfetch are emulated server-side, so plain chats can
search and read pages. Search calls the same two hosted MCP backends the
opencode client uses, anonymously: Parallel (search.parallel.ai/mcp,
web_search) first, Exa (mcp.exa.ai/mcp, web_search_exa) as fallback
when Parallel fails or is rate-limited. No key, no scraping, no captchas.
Fetch pulls the URL directly and returns text: http is upgraded to https,
only ports 80/443, no auto-redirects (each hop is re-checked), DNS is
resolved and must be entirely public (loopback, private, link-local, CGNAT,
ULA rejected), body capped at 2 MiB and output at 20000 chars. Every other
tool still gets the unavailable text. Emulation never shadows
FORWARD_CLIENT_TOOLS: a compatible client tool is forwarded and executed
by the client instead.
Search egress is direct (st.http) to the MCP backends; those are APIs, not
scraped search pages, so the datacenter IP is fine and the proxy pool stays
reserved for upstream opencode.ai routing.
The chat UI shows every executed call as a dim line (Parallel Web Search "…", Exa Web Search "…", Web Fetch "…"), sent in a nonstandard
oc_tool delta field that other OpenAI clients ignore.
MAX_TOOL_ROUNDS=0 ./target/release/oc # single attempt, no follow-ups
Forwarding client tools (FORWARD_CLIENT_TOOLS=1)
Off by default. When on, client-declared tools are appended upstream
next to the template (verified: extras don't 403, verified on both
chat/completions and responses), so the model can actually call them.
A round that calls only client tools is returned to the client as-is
(tool_calls + finish_reason: tool_calls, streamed as deltas) — the
client executes them and sends back role=tool results, the loop
continues upstream. Same-name client tools are compared by content:
identical to the template (e.g. opencode talking to oc) they forward like
client tools — the model never saw another schema, so args always fit.
Same name but different schema, unknown names and mixed rounds are still
stubbed as above, but with forwarding on the stub then
names the client tools the model may use instead (with forwarding off the
classic message is kept, so the model isn't tempted to hallucinate calls
to tools it cannot see). In NixOS:
services.oc-zen-proxy.forwardClientTools = true;.
Proxy failover + retries
Every outbound path (direct + each proxy) has its own cooldown: a 429
parks that route for RATE_LIMIT_COOLDOWN_SECS (default 120s, doubled per
repeat up to RATE_LIMIT_MAX_SECS, Retry-After wins when present —
upstream FreeUsageLimitError carries no timing itself),
a transport error or 5xx parks it for ROUTE_ERROR_COOLDOWN_SECS
(default 30s). Each logical turn is sent up to MAX_UPSTREAM_ATTEMPTS
times (default 3) with exponential backoff (RETRY_DELAY_MS, default 800ms),
skipping parked routes — so a rate-limited direct is followed by proxy
attempts within the same client request, and a route that failed minutes
ago is not retried until its cooldown expires. When every route is parked,
the turn does one live parallel recheck of all egress and uses whoever
answers; only if nobody does, the client immediately gets the last real
upstream 429 body verbatim, with no further waiting.
Retried: transport errors and 429/500/502/503/504. Permanent errors
(400/401/403/404/422, incl. geo-block 403) return immediately.
A 429 whose body says Endpoint is unavailable is a provider-side outage
of that model, not our egress: it fails fast to the client without parking
the route or burning retries through other egress.
A counter of consecutive retryable failures at PROXY_FAILOVER_AFTER
switches the preferred order to proxies-first for PROXY_COOLDOWN_SECS
(default 180s): proxy successes keep the window open, the first direct
success resets the counter and returns to direct. Switches are visible
in logs (route rate-limited, cooling down / failing over to proxies /
retrying upstream request ... / back to direct, passwords never logged).
export PROXIES="socks5h://u1:p1@1.2.3.4:1080,socks5h://u2:p2@5.6.7.8:1080"
export PROXY_FAILOVER_AFTER=2
./target/release/oc
# or via file: PROXIES_FILE=/run/secrets/oc-proxies
Egress health
At startup every proxy is probed through to https://1.1.1.1/cdn-cgi/trace in parallel
(5s timeout); proxies that do not answer are dropped and never enter rotation.
Limits are per model and IP, so probes run
one inference request (template + tiny cap) per probed model (unset
PROBE_MODELS = the first three live free catalog models) in that model's
wire format. Model-level failures — a geo-blocked model (RegionError), a
model whose serving endpoint is down (... Endpoint is unavailable) or a
rate-limited model (429) — skip to the next probe model without parking
the route; the route parks only on egress-level errors. /models alone is never
probed, it is not rate-limited and reports healthy on dead IPs. Probes run on
manual /v1/update and automatically as a live recheck when every route is
parked; traffic successes unpark at once.
Manual refresh: curl host/v1/update deep-probes every egress in parallel,
one IP MODEL STATUS line each, no credentials:
138.99.37.196 beam-free 200. Dead routes park themselves,
recovered ones rejoin rotation at once.
Pool status: curl host/v1/health live-checks every egress in parallel with
a short upstream request (3s timeout) and lists only the reachable ones (one host per line,
parked ones omitted). Unreachable egress is hidden rather than parked.
NixOS
nix build .#default
Wire up the module (flake input oc) — the package is substituted
automatically, no overlay needed:
{
inputs.oc.url = "path:/home/lily/Documents/oc"; # or git URL
# ...
imports = [ oc.nixosModules.default ];
services.oc-zen-proxy = {
enable = true;
port = 1234;
host = "127.0.0.1"; # localhost only, nginx faces outward; 0.0.0.0 listens everywhere
apiKeyFile = "/run/secrets/oc-api-key"; # or apiKey = "..."
authTokenFile = "/run/secrets/oc-tokens"; # one key per line; protects /v1/*
webUi = true; # serve the chat page at /chat (off by default)
proxiesFile = "/var/lib/oc/proxy"; # auto-created with a template if missing
failoverAfter = 3;
};
}
Secrets outside git and the store
proxiesFile/apiKeyFile in the module are plain strings, not nix paths:
the file is read at systemd runtime and never copied anywhere. Keep it
e.g. in ~/secrets/socks5 — no need to commit it, no --impure needed:
services.oc-zen-proxy.proxiesFile = "/home/lily/secrets/socks5";
Format: one URL per line, # comments. Two requirements: the file must
exist on the target machine and be readable by the service user — if home
is closed off (chmod 700), allow traversal:
chmod o+x /home/lily /home/lily/secrets && chmod o+r /home/lily/secrets/socks5
(or ACLs via setfacl). For production, sops-nix/agenix with files
in /run/secrets/ is the right call.
In the NixOS module it's even easier: set proxiesFile and the rest
happens by itself — on every nixos-rebuild switch the dir and file
are created if missing (template inside) and chowned to the service
user. Content of an existing file is never touched.
Uncomment the lines, add your proxies, then systemctl restart oc-zen-proxy.
Public access via plain nginx: https://ai.example.com/v1
The module doesn't manage nginx — just drop a standard virtualHost next to it:
services.oc-zen-proxy = {
enable = true;
host = "127.0.0.1"; # localhost only, nginx faces outward
port = 1234;
proxiesFile = "/run/secrets/oc-proxies";
};
services.nginx = {
enable = true;
virtualHosts."ai.example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:1234/";
proxyWebsockets = true;
recommendedProxySettings = true;
# SSE streaming + slow free-tier turns (tool rounds can take minutes).
extraConfig = ''
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
'';
};
};
};
Check: curl https://ai.example.com/v1/models.
If authTokenFile is set, this vhost is protected without any nginx auth: API
clients send Authorization: Bearer <token>, and the web page at /chat
asks for it. Keep / and /chat reachable (the page needs the token field).
For a second, browser-level lock use plain basicAuth in the location
instead — clients like LM Studio cannot do HTTP basic auth, so pick one.
All options are in nix/module.nix.
Limitations
- ~10k prompt tokens of overhead per request — same as opencode itself sends, the proxy adds nothing on top (and even saves the TUI title requests). But unlike opencode, LM Studio doesn't compact history — long dialogs hit the context sooner.
- Model persona is opencode (coding assistant), not neutral.
- Reasoning deltas (
nemotron) are folded intoreasoning_contentin non-stream responses. - The
/chatpage is deliberately session-less: reloading starts over (transcript and settings except the token/model live in memory only). - Client tools are ignored unless
FORWARD_CLIENT_TOOLS=1(the template carries the exact opencode tool set). Upstream tool calls are otherwise auto-answered as unavailable (see "Tool-call loop"), so coding questions get a text answer instead of workspace actions. - Each tool round re-sends the growing history: multi-round turns cost extra prompt tokens.
- Free models sometimes answer
503 Upstream overloadedor429 Endpoint is unavailable(provider-side outage, not your IP) — retry. Some have geo-blocks. The roster lives in the catalog and rotates; retired models are marked deprecated there and drop out of the listing automatically. - Template is hardcoded: if opencode updates system/tools/UA, expect
403until assets are re-captured.