caft/blog

opencode.json: add a custom provider and models to OpenCode

OpenCode reads opencode.json from up to three places and merges them. We added a custom provider on OpenCode 2.2.2, then broke it on purpose to see the errors.

OpenCode keeps its settings in opencode.json, usually the one at ~/.config/opencode/opencode.json, but a repo can carry its own copy at the root. A custom provider is a single block in that file. We wrote one for caft, then spent an afternoon breaking it on purpose (a made-up model name, a variant that doesn't exist, a base URL with no /v1, a key the service never saw) to collect the errors further down.

All of it ran on OpenCode 2.2.2 on November 1, 2026. Nothing in the block is tied to caft. If your provider speaks OpenAI's Chat Completions API, change the base URL and the key and the rest carries over.

Where does opencode.json live?

OpenCode checks three places for the file:

Want comments? Name the file .jsonc. One of our test files opened with a // line explaining itself, and OpenCode loaded it without complaint.

OpenCode stacks the files rather than choosing one: global first, then OPENCODE_CONFIG, then the project file, and on any key two of them share, the later one wins. So a project file can change the default model and still use the provider from your global file.

The merge reaches inside nested blocks too. In one test, an OPENCODE_CONFIG file held nothing but a new caft baseURL. Requests went to that URL, with the model list and the key reference from the global file.

To see what OpenCode loaded, run opencode debug config. It prints each source in load order and masks API keys as ***. The OpenCode config docs list a few more sources, such as managed files an admin can push to company machines.

Add a custom provider to opencode.json

This block adds caft as a provider with two models, in the OpenCode 2 format. Put your caft API key in CAFT_API_KEY before you start OpenCode.

JSON
{
  "$schema": "https://opencode.ai/config.json",
  "model": "caft/glm-5.3",
  "small_model": "caft/glm-5.3-flash",
  "providers": {
    "caft": {
      "name": "caft",
      "package": "aisdk:@ai-sdk/openai-compatible",
      "settings": {
        "baseURL": "https://api.caft.dev/v1",
        "apiKey": "{env:CAFT_API_KEY}"
      },
      "models": {
        "glm-5.3": { "name": "GLM 5.3" },
        "glm-5.3-flash": { "name": "GLM 5.3 Flash" }
      }
    }
  }
}

What each part does:

A model can take a limit with context and output, and this is the default that surprised us. With no limit set, every request OpenCode 2.2.2 sent carried max_completion_tokens: 32000, even when the whole reply was the word pong. Add "output": 16000 and the number in the request drops to match. The provider docs say built-in providers get these numbers from models.dev, so a custom provider is where you set them by hand:

JSON
"glm-5.3-flash": { "name": "GLM 5.3 Flash", "limit": { "context": 1000000, "output": 16000 } }

Any model ID on the caft models page works in this block. A monthly plan covers some of them, and the plans page lists which ones.

The OpenCode 1.x format

OpenCode's provider docs and the OpenCode setup in the caft docs show the older shape: provider, npm and options. OpenCode 2.2.2 still reads it. We loaded the block below, and opencode debug config printed it back in the 2.x shape, with npm turned into "package": "aisdk:@ai-sdk/openai-compatible". So a 1.x file keeps working after an upgrade. Run opencode --version to see which major version you have.

JSON
{
  "$schema": "https://opencode.ai/config.json",
  "model": "caft/glm-5.3",
  "small_model": "caft/glm-5.3-flash",
  "provider": {
    "caft": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "caft",
      "options": {
        "baseURL": "https://api.caft.dev/v1",
        "apiKey": "{env:CAFT_API_KEY}"
      },
      "models": {
        "glm-5.3": { "name": "GLM 5.3" },
        "glm-5.3-flash": { "name": "GLM 5.3 Flash" }
      }
    }
  }
}

Choose model and small_model

model sets the model a new session starts on. small_model covers light jobs, such as naming each session. Both take the provider/model form.

Every new session in our tests sent two requests. The first asked small_model for a title, with no tools attached and reasoning_effort: "low". The second carried the prompt and 12 tool definitions to model. In a file with one caft model and no small_model, the title request went to that one model.

Point small_model at a cheap, fast model. Titles are short, but OpenCode asks for one on every session.

Reasoning effort

Add #low, #medium or #high to a model ID to set its reasoning effort. OpenCode sends the suffix as reasoning_effort in the request.

Shell
opencode run -m caft/glm-5.3#high "Find the cause of the failing test in src/cart.test.ts"

We didn't define any variants in the config, and all three values worked on the custom provider. Through caft, caft/glm-5.3#low and caft/glm-5.3#high each answered a one-line prompt in 2 to 3 seconds. Our OpenCode agent team gives each agent a variant in the same provider/model#variant form.

A value OpenCode doesn't know stops the run, and your prompt never goes out:

Text
Error: Variant unavailable for caft/glm-5.3: bogus

One request still slips through first. OpenCode sends the session title request to small_model before it checks the variant. So the failed run shows up in your usage log anyway, as one small request.

Check the setup with opencode models

opencode models prints every model OpenCode can use, one provider/model per line. Filter it with grep:

Shell
opencode models | grep caft/

With the block above, it prints two lines:

Text
caft/glm-5.3
caft/glm-5.3-flash

Don't pass the provider as an argument. Older guides, ours included, said to run opencode models caft. OpenCode 2.2.2 rejects it with Unexpected positional argument: "caft", and the caft docs now use the grep form.

Then send one real prompt:

Shell
opencode run -m caft/glm-5.3-flash "Reply with the word pong."

The reply is pong, under a line that names the agent and the model, such as > build · glm-5.3-flash. A model ID that isn't in the block stops the run with Error: Model unavailable: caft/glm-9. As with a bad variant, only the title request goes out.

Why does OpenCode say Invalid API key?

In OpenCode 2, check the background service first. The TUI and opencode run both talk to a service process, and that process fills in {env:...} values from its own environment. A key you export later, in another shell, never reaches it.

To prove it, we started the service from a terminal with no CAFT_API_KEY, then ran a prompt from a second terminal where the key was exported. OpenCode sent the request with no Authorization header at all, and caft answered:

Text
Error: Invalid API key. Pass it as `Authorization: Bearer sk-...`, and check the key has not been revoked.

The fix is one command, run from the terminal that has the key:

Shell
export CAFT_API_KEY=sk-...
opencode service restart

Rotating a key? Same thing. The service keeps the old one until it restarts. OpenCode gives no warning for an unset variable. The config docs say {env:...} becomes an empty string, and the missing header in our test fits that.

Other errors we hit

Provider request failed with HTTP 404: Not Found came from a baseURL with the /v1 chopped off. caft answers that path with a bare Not Found, and OpenCode shows you the status and that text. Put /v1 back on the end.

Invalid API key is vaguer than it looks. caft sends the exact same message when no key arrives at all and when a wrong or revoked one does, so the text alone won't tell you which happened. Rule out the service first (see above), then check the key against the one on your dashboard.

With one model working, the next step is a model for each job. Our OpenCode agent team runs fourteen agents on one caft key, with cheap models writing the code and other model families reviewing it.