DEV Community

Lynkr
Lynkr

Posted on

How I Built a Free OpenAI-Compatible API on Top of OpenCode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/v1 as a generic OpenAI endpoint, and the free model becomes just another provider in the routing tier.
  • Or skip the gateway for chores: my opencode.jsonc defines a direct wrap provider and sets it as small_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."}]}'
Enter fullscreen mode Exit fullscreen mode

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)