OpenAI-compatible API proxy for opencode — exposes opencode serve as a standard /v1/chat/completions endpoint.
Built with Node.js and Express. Drop-in replacement for any OpenAI client.
- OpenAI-compatible — drop-in replacement for any OpenAI client
- Free models — lists opencode free models (
deepseek-v4-flash-free,qwen3.6-plus-free, etc.) - True streaming — forwards backend tokens live via
prompt_async+ the backend event bus (chat & text completion), withstream_options.include_usage - Conversation memory — pass
session_idto keep multi-turn context in one backend session instead of replaying history on every request - Thinking output — backend reasoning streams as
reasoning_contentdeltas (opt-in viainclude_reasoning: true) and non-streaming replies carrymessage.reasoning_content - System prompts — maps
systemrole to opencode's system prompt - Image input — supports
image_urlfor vision-capable models - Text completions — legacy
/v1/completionsendpoint - API key auth — optional
API_KEYenv var for bearer token auth - Resilient — retries on network failures, configurable timeouts, OpenAI-shaped errors
- Model mapping — bare model name →
opencode/<model>provider, orprovider/model - Session inspector —
--session [<id>]CLI queries backend sessions as JSON - Health check —
GET /healthandGET /
npm install -g opencode-api-node
# then run it (flags win over env vars)
opencode-api-node --port 55890 --opencode_url http://127.0.0.1:4096 --api_key sk-mykeyOr without installing:
npx opencode-api-node --port 55890Or from source:
git clone https://github.com/adnxone/opencode-api-node.git
cd opencode-api-node
npm install# point at a running `opencode serve` (default http://127.0.0.1:4096)
PORT=55890 node server.js
# or via CLI flags (flags win over env vars)
node server.js --port 55890 --opencode_url http://127.0.0.1:4096 --api_key sk-mykey
# or with the global binary (same flags)
opencode-api-node --port 55890Query session data straight from the backend (prints JSON, no server started):
node server.js --session # list all sessions
node server.js --session ses_f40... # one session, including its messagesThe adapter proxies to a running opencode serve — start one first if it isn't
already (it does not start the backend for you, except inside Docker):
opencode serve --hostname 127.0.0.1 --port 4096Run with docker:
docker build -t opencode-api-node .
docker run -d -p 80:80 -e API_KEY=sk-mykey opencode-api-node
# or with docker-compose
API_KEY=sk-mykey docker compose up -d --build
# without auth (API_KEY not set → no auth required)
docker run -d -p 80:80 opencode-api-nodecurl http://localhost:80/health
# {"status":"ok","opencode":true}curl http://localhost:80/v1/modelscurl -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-mykey" \
-d '{
"model": "deepseek-v4-flash-free",
"messages": [
{"role": "system", "content": "You are a pirate. End every sentence with arr!"},
{"role": "user", "content": "Hello there"}
],
"stream": false
}'curl -X POST http://localhost:80/v1/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-mykey" \
-d '{
"model": "deepseek-v4-flash-free",
"prompt": "Once upon a time",
"max_tokens": 100,
"stream": false
}'Tokens arrive as the backend produces them (not buffered):
curl -N -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-mykey" \
-d '{
"model": "deepseek-v4-flash-free",
"messages": [{"role": "user", "content": "Tell me a joke"}],
"stream": true,
"stream_options": {"include_usage": true}
}'Without session_id every request gets a fresh backend session (earlier turns
are replayed into it). Pass session_id to pin all turns to one backend
session — then each request only needs the new message:
# turn 1 — the response includes a session_id
curl -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash-free",
"messages": [{"role": "user", "content": "My name is Ada"}]
}'
# {"id":"chatcmpl-...","session_id":"ses_f40...","choices":[...],...}
# turn 2 — same session, context kept server-side
curl -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash-free",
"session_id": "ses_f40...",
"messages": [{"role": "user", "content": "What is my name?"}]
}'Unknown ids return 404 {"error": {"code": "session_not_found", ...}}.
curl -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.6-plus-free",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}
]
}
]
}'| Environment Variable | Default | Description |
|---|---|---|
API_KEY |
(empty) | Bearer token for auth. Omit to disable auth. |
OPENCODE_URL |
http://127.0.0.1:4096 |
Backend opencode serve URL |
PORT |
80 |
Adapter listening port |
OPENCODE_TIMEOUT |
15000 |
Control-plane backend timeout in ms (health, models, sessions) |
OPENCODE_MESSAGE_TIMEOUT |
300000 |
Generation timeout in ms (blocking and streaming) |
OPENCODE_RETRIES |
1 |
Extra attempts on network-level backend failures |
STREAM_HEARTBEAT_MS |
15000 |
SSE heartbeat interval in ms for streams (0 disables) |
ENABLE_STREAMING |
1 |
Master switch: 0 serves stream: true requests blocking as JSON |
HOST |
0.0.0.0 |
Bind address (127.0.0.1 = localhost only) |
CORS_ORIGIN |
(empty) | Enable CORS for this origin (* for any); empty = off |
VISION_MODEL |
qwen3.6-plus-free |
Vision model forced when messages contain images |
INCLUDE_REASONING |
0 |
Default-on thinking when requests say nothing |
LOG_LEVEL |
info |
error|warn|info|debug request logging (no bodies/keys) |
CLI flags override the env vars above: --port <n>, --opencode_url <url>
(--opencode-url also works), --api_key <key> (--api-key also works),
--streaming / --no-streaming (overrides ENABLE_STREAMING),
--host <addr>, --cors <origin>, --vision_model <m>,
--include_reasoning / --no-include_reasoning,
--log_level <lvl>, --version (-v).
--session [<id>] queries session data from the backend instead of starting
the server.
Copy .env.example to .env and adjust it to your setup. node server.js
reads .env automatically (built-in loader, no dotenv dependency);
docker compose loads .env automatically too. Precedence: CLI flags >
shell env > .env file > defaults.
Chat completions expose backend thinking as reasoning_content
(DeepSeek/OpenRouter convention):
# streaming — opt in, otherwise thinking is dropped and content stays clean
curl -N -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "deepseek-v4-flash-free",
"messages": [{"role": "user", "content": "Think step by step"}],
"stream": true, "include_reasoning": true}'
# deltas arrive as {"reasoning_content": "..."} alongside {"content": "..."}
# non-streaming — included automatically when the backend produced thinking
curl -X POST http://localhost:80/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "deepseek-v4-flash-free",
"messages": [{"role": "user", "content": "Hello"}]}'
# {"choices": [{"message": {"role": "assistant", "content": "...",
# "reasoning_content": "..."}}], ...}Aliases accepted: include_thinking, stream_options.include_reasoning,
reasoning / reasoning_effort, thinking / enable_thinking
(reasoning: { effort: "none" } disables thinking output). These only gate
adapter output — effort itself is tuned via opencode model variants, and the
aliases are never forwarded to the backend. Any model that exposes plaintext
reasoning works: delta fields reasoning / reasoning_text / thinking,
text deltas on reasoning parts, and snapshot deltas are all covered.
usage gains completion_tokens_details.reasoning_tokens when the backend
reports it. /v1/completions (legacy text endpoint) omits thinking — use chat
completions for reasoning models. Models that keep thinking encrypted
server-side expose counts only, no text. Per-model backend failures that
arrive as HTTP 200 with info.error (e.g. insufficient funds) surface as
500 backend_error (non-streaming) or a terminal error frame (streaming)
instead of an empty success.
npm testRuns test/api.test.js (Node built-in test runner) against a mock opencode
backend — no real opencode serve needed.
┌─────────────┐ /v1/chat/completions ┌──────────────┐ session API ┌──────────────┐
│ OpenAI SDK │ ──────────────────────────► │ server.js │ ──────────────────► │ opencode │
│ curl / any │ ◄────────────────────────── │ (port 80) │ ◄────────────────── │ serve │
└─────────────┘ OpenAI format └──────────────┘ session API │ (port 4096) │
└──────────────┘
├── server.js # Express app: OpenAI → opencode proxy
├── test/api.test.js # Tests with mock opencode backend (npm test)
├── .github/ # CI workflow + Dependabot config
├── package.json # Node deps (express only)
├── package-lock.json # Locked dependency tree
├── .env.example # Template config — copy to `.env` (gitignored) and adjust
├── .nvmrc # Pinned Node version for contributors
├── CHANGELOG.md # Release history
├── Dockerfile # Docker image with opencode + node deps
├── .dockerignore # Keeps node_modules/logs/tests out of the image
├── entrypoint.sh # Starts opencode serve, then adapter
├── docker-compose.yml
├── LICENSE # MIT
├── README.md # This file
└── .gitignore
MIT — see LICENSE.
Adrian Marian Paunescu — adrian@adnxone.eu
- GitHub: github.com/adnxone
- LinkedIn: linkedin.com/in/adnxone
- Instagram: instagram.com/adnxone
- Facebook: facebook.com/adnxone
- X: x.com/adnxone_
- Threads: threads.net/@adnxone
- TikTok: tiktok.com/@adnxone
- Sites: adnxone.eu | adhdadultiromania.eu | meetaxel.eu
- OpenAI-compatible routes with OpenAI-style JSON shapes: errors are
{ "error": { "message", "type", "code", "param" } }—401 invalid_api_keyfor bad auth,400 invalid_jsonfor malformed bodies,404 session_not_foundfor unknownsession_id,502 backend_unreachablewhenopencode serveis down. - Streaming forwards backend
message.part.deltaevents live and ends withdata: [DONE]; mid-stream failures arrive asdata: {"error": ...}. A client disconnect aborts the backend run. Passstream_options.include_usagefor ausageblock on the final chunk. Thinking streams asdelta.reasoning_contentonly withinclude_reasoning: true; reasoning parts are tracked viamessage.part.updatedso thinking never leaks intocontent. While the backend works (e.g. tool calls with no text deltas) the stream stays alive with an SSE heartbeat comment everySTREAM_HEARTBEAT_MS(default 15000 ms,0disables) — use streaming (not blocking) for long agentic tasks, and raiseOPENCODE_MESSAGE_TIMEOUTpast the longest expected run. session_id(also accepted assessionId) pins turns to one backend session; without it each request gets a fresh session with the history replayed. Responses echo the id back assession_id.- Backend GETs are retried on HTTP 5xx and everything is retried on
network-level failures (
OPENCODE_RETRIES, backoff);prompt_asyncis never retried (fire-and-forget). - Image URLs are fetched and inlined as
data:URLs (30s fetch timeout); any message containing an image forces the vision model (qwen3.6-plus-free). - Malformed JSON bodies return
400instead of an HTML error page. - Requires Node.js ≥ 18 (uses the built-in
fetch).