One command, zero API keys, and a surprising amount of protocol translation.
The problem
I kept reaching for an LLM API for the small stuff — renaming sessions, summarizing compaction output, little throwaway scripts, glue code in automation. Paying per-token for that felt silly, and the free tiers out there all wanted signups, keys, and dashboards.
Meanwhile, OpenCode had quietly shipped a free-tier model (muse-spark-1.3-contributor-free). The catch: it was only reachable through the OpenCode CLI. There was no HTTP API for it that my other tools could talk to.
So I built the missing piece: OpenCode-Wrap, a small server that puts an OpenAI-compatible API in front of opencode serve.
The core idea
Everything in the LLM tooling world speaks one protocol: OpenAI's /v1/chat/completions. If I could translate that protocol to whatever opencode serve speaks, then overnight, every script, agent framework, and coding tool I own would gain a free backend.
The architecture is dead simple:
caller → OpenCode-Wrap (:8000/v1) → opencode serve (:4100) → free model
The wrapper is a single Node process, zero npm dependencies, and it reuses the auth your opencode CLI already has. No API key to manage, no config to write:
npx opencode-wrap
# -> OpenAI-compatible API at http://127.0.0.1:8000/v1
If no opencode serve is reachable, it spawns its own on :4100 automatically.
Translating the protocols
This is where the real work lives. OpenAI's chat-completions format and opencode's session/event model are shaped differently, so every request goes through a translation layer:
1. Sessions are per-request. The wrapper is stateless — each incoming request creates a fresh opencode session, replays the full message history into it, and deletes the session when done. It's a little wasteful on paper, but it makes the server trivially simple and crash-safe: there's no session state to corrupt.
2. Streaming is real streaming. When stream: true comes in, backend message.part.delta events are forwarded as OpenAI SSE chunks the moment they arrive — no buffering the whole response first. If the event bus is unreachable, it falls back to buffered replay, and if the backend dies mid-stream (headers already sent), the failure is delivered in-band as an error chunk. That last case was a fun edge: you can't change the HTTP status after streaming starts, so the error has to ride inside the stream.
3. Tool calling is the fiddliest part. opencode serve has no custom-tool passthrough, so caller-supplied tools get translated via instruction injection plus tool_call fence parsing — the wrapper tells the model about the available tools in the prompt, then parses the fenced tool calls out of the response and returns them as proper OpenAI tool_calls. Opencode-native tools (bash/read/edit) just execute server-side automatically. One behavioral quirk worth knowing: under tool_choice: auto the model sometimes answers from knowledge instead of calling the tool — use required or a named tool to force it.
4. Errors are OpenAI-shaped. The free tier flakes — transient 500s happen. The wrapper retries those 3x with backoff on fresh sessions, and persistent failures surface as 429 (rate-limited) or 502 (bad gateway) so callers can apply their own retry/cascade logic without learning a new error vocabulary.
5. Small kindnesses. Model IDs get trimmed and aliased (wrap/muse-spark-free and friends fall back to the backend model with a log line), explicit opencode/<id> typos fail fast with HTTP 400 plus a suggestion, and empty contentless turns are retried on a fresh session instead of being served as a confusing stop.
Where it fits in my stack
I also built Lynkr, an LLM gateway for stretching subscriptions across providers. OpenCode-Wrap slots into it two ways:
- Point Lynkr at
http://127.0.0.1:8000/v1as a generic OpenAI endpoint, and the free model becomes just another provider in the routing tier. - Or skip the gateway for chores: my
opencode.jsoncdefines a directwrapprovider and sets it assmall_model, so session titles and compaction go straight to the free backend while the main model stays on Lynkr routing.
That second pattern is my favorite — the boring background work costs literally nothing now.
The fair-use part
This sits in front of a shared free-tier backend, so the wrapper is opinionated about being a good citizen: personal/light use only, it backs off and returns 429 when the backend says slow down, request bodies are capped at 8MB, and it reuses your CLI credentials — so localhost only, never expose it to a network you don't trust.
What I'd do differently
Honestly? The per-request session replay is the obvious inefficiency. A session pool with incremental history would cut latency for chatty workloads. But it would also add exactly the kind of stateful complexity that makes 2am debugging miserable, and for personal use the replay cost is negligible. Simple won.
Try it
npx opencode-wrap
curl http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"opencode/muse-spark-1.3-contributor-free",
"messages":[{"role":"user","content":"Explain closures in one paragraph."}]}'
Repo: https://github.com/Fast-Editor/OpenCode-Wrap
If you build something on it, I'd love to hear what you wired it into.
Top comments (0)