No description
  • Rust 76.2%
  • HTML 14.6%
  • Nix 5.9%
  • Shell 3.3%
Find a file
2026-10-20 17:31:53 +03:00
assets update 0.6.5 2026-10-20 17:31:53 +03:00
nix nixos: chown secret dirs only in ExecStartPre 2026-10-18 18:29:29 +03:00
scripts release: set rustup default toolchain in musl devShell 2026-10-18 14:57:53 +03:00
src chat: per-model reasoning levels; highlight C/Rust fixed-width types 2026-10-19 17:55:18 +03:00
.gitignore gitignore: drop releasewd.sh rule 2026-10-18 15:08:34 +03:00
AGENTS.md Retry via proxies, sticky failover, minimal comments 2026-09-18 15:15:44 +03:00
Cargo.lock update 0.6.5 2026-10-20 17:31:53 +03:00
Cargo.toml update 0.6.5 2026-10-20 17:31:53 +03:00
flake.lock first commit 2026-09-18 09:54:36 +03:00
flake.nix flake: drop stale devshell comments 2026-10-18 00:25:25 +03:00
README.md session: ignore malformed x-opencode-session, upstream 403s junk ids 2026-10-19 16:51:34 +03:00

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 from basic, plus full client JSON, upstream JSON (system redacted to length) and raw upstream SSE, all capped by LOG_MAX_CHARS (dumps use max*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 per packages/schema/src/identifier.ts (first 12 hex = timestamp_ms * 0x1000 + counter, then 14 base62); random IDs are rejected by timestamp check, and a missing x-opencode-session-id answers 403 FreeTierError. msg_ is fresh per request, ses_ is sticky per dialog (see below)
  • stream: true upstream always (with stream:false upstream answers 403, so for non-streaming clients SSE is reassembled back into one chat.completion)
  • the body is wrapped in the exact opencode run template: 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 with FORWARD_CLIENT_TOOLS=1, otherwise ignored. Upstream tool_calls reach 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 into reasoning_content in non-stream responses.
  • The /chat page 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 overloaded or 429 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 403 until assets are re-captured.