opencode is a terminal coding agent, and it has no opinion about where its tokens
come from. Providers are declared in an opencode.json file, so pointing it at a model running on your own
machine — LM Studio, an MLX server, llama.cpp, vLLM — is a matter of a baseURL and a couple of lines of
metadata. Here’s a config with three providers side by side: a LAN box, a hosted API, and a local server.
Where the file goes
Two locations, and they merge:
~/.config/opencode/opencode.json— global, follows you between projects./opencode.jsonat the project root — per-project, committed with the repo if you like
The merge is per-key and deep, which is more useful than it sounds: a global provider block plus a project
block for the same provider ends up as one provider with the union of the models, and the project’s
options winning where both define the same key. Handy for keeping the machine-specific baseURL
project-side while the model catalog lives globally. $schema gives you autocompletion and validation in
any editor with JSON language support.
The file
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lmstudio": {
"npm": "@ai-sdk/openai-compatible",
"name": "LM Studio",
"options": {
"baseURL": "http://192.168.50.9:1234/v1"
},
"models": {
"gemma-4-12B-it-MLX-6bit": {
"name": "gemma-4-12B-it-MLX-6bit"
},
"qwen/qwen3.8-27b": {
"name": "qwen3.8-27b"
}
}
},
"mistral-api": {
"npm": "@ai-sdk/openai-compatible",
"name": "Mistral",
"options": {
"baseURL": "https://api.mistral.ai/v1/",
"apiKey": "{env:MISTRAL_API_KEY}"
},
"models": {
"mistral-medium-3.5": {
"name": "Mistral Medium 3.5"
}
}
},
"omlx": {
"npm": "@ai-sdk/openai-compatible",
"name": "oMLX",
"options": {
"baseURL": "http://localhost:8000/v1",
"apiKey": "{env:OMLX_API_KEY}"
},
"models": {
"DeepSeek-R1-Distill-Qwen-14B-4bit": {
"name": "DeepSeek-R1-Distill-Qwen-14B-4bit",
"reasoning": true,
"modalities": {
"input": ["text"],
"output": ["text"]
},
"limit": {
"context": 32768,
"output": 32768
}
}
}
}
},
"model": "omlx/DeepSeek-R1-Distill-Qwen-14B-4bit"
}Anatomy
The provider key is an identifier you invent — lmstudio, omlx, mistral-api. It becomes the prefix
you type everywhere (omlx/DeepSeek-R1-Distill-Qwen-14B-4bit), so name it after what it actually is. Calling
an LM Studio endpoint dmr will confuse you in three weeks when you also add Docker Model Runner.
npm: "@ai-sdk/openai-compatible" is the Vercel AI SDK package used to talk to the endpoint. That one
covers everything that speaks /v1/chat/completions, which is essentially every local server.
options is passed straight to the SDK: baseURL (include the /v1) and apiKey. {env:VAR} reads an
environment variable. Most local servers ignore the key entirely, but some clients refuse to send an empty
one — a dummy string is fine there.
models maps wire IDs to metadata. The key is what gets sent in the request body, so it must match
exactly what the server serves — note qwen/qwen3.8-27b, org prefix included, because that’s how LM Studio
reports it. name is only the label in the picker.
The metadata matters more for local models than hosted ones. opencode pulls specs for known models from
models.dev, but it has never heard of your 4-bit MLX quant, so limit.context /
limit.output and reasoning are how it learns the context budget to plan against and whether to expect
<think> blocks. Skip them and you get defaults that may not match the server.
model at the top level is the default selection. Override per-run with -m, or pick another from the
model list inside the TUI.
Check what actually loaded
opencode models <provider> resolves the whole config and lists what’s reachable:
opencode models omlxomlx/DeepSeek-R1-Distill-Qwen-14B-4bitAdd --verbose for the resolved metadata, which is where you see whether your limit and reasoning landed:
{
"id": "DeepSeek-R1-Distill-Qwen-14B-4bit",
"capabilities": {
"temperature": false,
"reasoning": true,
"attachment": false,
"toolcall": true
},
"cost": { "input": 0, "output": 0, "cache": { "read": 0, "write": 0 } },
"limit": { "context": 32768, "output": 32768 }
}Zero cost across the board, which is the whole point — and it means opencode stats stays honest about
which sessions actually spent money.
For the merge question (“which file won?”), opencode debug config prints the final merged object:
opencode debug configThree gotchas
Unknown keys are dropped in silence. A baseUrl at the provider level instead of options.baseURL is
the classic one. It doesn’t error, it doesn’t warn, the provider still shows up in opencode models — it
just disappears from the resolved config and requests go somewhere you didn’t intend:
--- mistral-api {"npm": "@ai-sdk/openai-compatible", "name": "Mistral",
"models": {"mistral-medium-3.5": {"name": "Mistral Medium 3.5"}}}No options at all. This is exactly what $schema and opencode debug config are for.
An unset {env:VAR} becomes an empty string, not an error. With OMLX_API_KEY missing, the resolved
config shows "apiKey": "" and you find out at request time, from whatever the server decides to answer.
Export it, put it in your shell profile, or hardcode a dummy for a local endpoint that doesn’t care.
Model IDs are the server’s, not yours. If a request 404s or comes back complaining about the model, list what the server actually exposes before touching the config:
curl -s http://localhost:8000/v1/models | jq -r '.data[].id'Worth pairing with
The same local endpoint can back more than one tool. If you want a declarative agent with its own toolset
rather than a coding TUI, Docker Agent reads a comparable YAML and
points at the same http://localhost:8000/v1. One model server, two front-ends, no tokens leaving the LAN.
Tested with opencode 1.18.21.