avatar vian's notes

Learning in public, one post at a time

  • HOME
  • TAGS
  • ARCHIVES
  • ABOUT
Home OpenCode V2 + 9router: 9 Traps in a Custom Provider Setup
Post

OpenCode V2 + 9router: 9 Traps in a Custom Provider Setup

Posted Oct 23, 2026 Updated Oct 18, 2026
By Alvian
7 min read
OpenCode V2 + 9router: 9 Traps in a Custom Provider Setup
OpenCode V2 + 9router: 9 Traps in a Custom Provider Setup

If you’re pointing OpenCode V2 at a provider you run yourself — your own router, a proxy, a local model server — this is the list of things that will quietly waste your time.

I hit all nine. Trap #1 cost me two days, and the error message blamed the wrong machine for the entire time. They’re ordered roughly the way you’ll hit them.

The Short Version

TrapWhat you’ll see
1. Wrong provider idWrong protocol used, no error at startup
2. V1 config shapeYour config block is ignored entirely
3. Credential only in envMissing auth credential: apiKey
4. Whitespace in baseURLRequests fail or land nowhere
5. Assuming your server brokeAn error that names your server
6. Reading the error top-downYou debug the last layer, not the first
7. Trusting the UIYou verify the wrong thing
8. Trusting preview dataA regression that isn’t one
9. Letting a tool write your configSilent data loss

Do This Before You Start

Whatever else you do, take a copy. Every other trap is an hour of your life; trap #9 is your config.

1
cp ~/.config/opencode/opencode.json ~/.config/opencode/opencode.json.bak

The Shape You’re Aiming For

So you’re not debugging against a fragment. This is a complete, working V2 custom provider, trimmed to one entry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "$schema": "https://opencode.ai/config.json",
  "providers": {
    "my-router": {
      "name": "My Router",
      "package": "@opencode/ai/providers/openai-compatible-responses",
      "settings": {
        "baseURL": "https://my-router.example/v1",
        "apiKey": "sk-..."
      },
      "models": {
        "General": { "modelID": "General", "name": "General" }
      }
    }
  }
}

Four things to note. The provider sits under providers, keyed by whatever name you’ll want to type later. package is a hyphenated id. The endpoint and key live in settings. And every model needs both a modelID and a display name. Hold your file next to this and most of the traps below explain themselves.

1. The provider id uses a hyphen, not a slash

This is the one that cost me two days.

1
2
"package": "@opencode/ai/providers/openai-compatible/responses"    // wrong
"package": "@opencode/ai/providers/openai-compatible-responses"    // right

The slash form isn’t invalid — it resolves to a package. Just not the one that selects the Responses transport. So OpenCode loads something plausible, takes the chat-completions path, and sends a Responses-shaped body down it.

There is no “unknown provider package” warning, because nothing failed at the lookup. If you’re generating this id from a template, a copy-paste, or a tool’s config writer, verify the exact string before you debug anything else.

Check it with: grep -c 'openai-compatible/responses' ~/.config/opencode/opencode.json — should be 0. If it prints 1, stop reading and fix that first.

2. A V1-shaped config block is silently ignored

V1 selected providers with provider, npm: "@ai-sdk/openai-compatible", and options. V2 uses providers and settings. A tool that still emits the V1 shape doesn’t error — V2 just doesn’t see it.

If you migrated and your provider “looks configured” but OpenCode behaves as if it isn’t, this is why.

Check it with: grep -c '"npm"' ~/.config/opencode/opencode.json — should be 0 in a V2 config.

3. Put the credential in settings.apiKey, not just env

This block looks complete and isn’t:

1
"env": ["ROUTER9_API_KEY"]

It works exactly as long as that variable is set in the shell you happen to be using. Run it from a different shell, a CI job, an editor plugin, or a preview container and you get:

1
Error: Missing auth credential: apiKey

The durable form carries the credential:

1
2
3
4
"settings": {
  "baseURL": "https://9router.vianhanif.link/v1",
  "apiKey": "sk-..."
}

Rule: a config that depends on an env var is not the same as a config that carries the credential. If a machine is allowed to differ, this will differ.

4. Whitespace in baseURL

Mine had a leading space. It survived editing, looked fine, and produced a URL that didn’t resolve the way I expected. In a JSON file, "baseURL": " https://…" is a valid string with a junk character at the front.

Check it with: grep baseURL ~/.config/opencode/opencode.json | cat -et — on macOS cat -et marks line ends with $, which makes a stray leading space obvious. (cat -A is GNU-only; a Mac answers illegal option -- A.)

5. Rule out your server before you doubt your client

When an error names your server, don’t trust the name — test the server by hand:

1
2
3
4
curl -s -X POST https://your-router.example/v1/responses \
  -H "Authorization: Bearer $YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"General","input":"reply OK","stream":false}'

General is 9router’s model id — substitute whatever your own router accepts. What matters here is the status code, not the model.

Mine answered 200 the entire time the agent was failing. Thirty seconds of curl eliminated the entire server side of the problem and told me the difference had to be in the request the client was sending.

6. One error message usually means two failures

Mine was a chain, and the visible error was the last link:

  1. OpenCode sent the request to the wrong route.
  2. Your server correctly rejected it — property 'endpoint' is unsupported.
  3. The client’s response validator rejected the reply — SchemaError item.namespace.

Read that from the bottom and you’ll debug the validator. Read it from the top and you’ll find the transport. Start where the request is issued, not where the error is printed.

7. Verify what the tool wrote, not what the UI says

If a dashboard, wizard, or config generator wrote your file, don’t confirm it by looking at the dashboard. Look at the artifact:

1
2
# what did the tool actually put on disk?
grep -c "openai-compatible-responses" ~/.config/opencode/opencode.json

In my case the same check inside the built container was the only thing that proved the fix had shipped — the UI happily rendered the new format while the deployed build still emitted the old one. A UI reflects intent. The artifact reflects reality.

8. Preview data isn’t prod data

A preview environment seeded from prod will happily route you to a model that has since been deprecated upstream. Mine returned:

1
410 ModelDeprecated

…while prod returned 200 for the same request. Check it with: send the same request to both hosts and compare status codes.

1
2
3
4
5
6
7
for h in preview.example prod.example; do
  curl -s -o /dev/null -w "$h %{http_code}\n" -X POST "https://$h/v1/responses" \
    -H "Authorization: Bearer $YOUR_KEY" -H "Content-Type: application/json" \
    -d '{"model":"General","input":"ping","stream":false}'
done
# prod.example 200
# preview.example 410   <- same code, different data

Nothing was wrong with the code. Before you treat a preview-only failure as a regression, confirm the preview’s data matches what you’re comparing against.

9. Back up your config before any tool writes it

Every trap above is recoverable. This one isn’t always.

1
cp ~/.config/opencode/opencode.json ~/.config/opencode/opencode.json.bak

The reason is in the other half of this story: the same tool that writes your config had a path where a file that failed to parse was treated as an empty config, then written over. I only found that one because writing up trap #1 forced me to read this exact write path line by line. Backup is cheap insurance against a code path you haven’t read.

Triage Table

SymptomMost likely trap
Wrong protocol used, no startup error1
Provider configured but ignored2
Missing auth credential: apiKey3
Request fails to reach the server at all4
Error names your server5, 6
Works locally, fails in another environment3
“It’s fixed” but behaviour didn’t change7
Fails only in preview8
Config looks smaller than you left it9

Sources

  • One Trailing Comma, One Wiped Config — trap #9, in full
  • OpenCode configuration
  • OpenCode providers
  • OpenCode V2 docs

Elsewhere in this series: Deciding to Upgrade · Saving Sessions After Laptop Swap · What Changed in OpenCode V2

opencode 9router technical tips guide
This post is licensed under CC BY-NC 4.0 by the author.
Share

Recently Updated

  • Saving OpenCode Sessions After Laptop Swap
  • How I Stopped Committing as the Wrong Person
  • Headroom, Round Two: Four Walls Between Me and a Working Token Saver
  • The Server Split That Almost Didn't Happen
  • I Opened My First Open Source PRs. None of Them Are Merged Yet.

Trending Tags

9router technical tooling opencode postmortem ade ai personal warp agents

Contents

Further Reading

Aug 9, 2026

Checking Upstream: What v0.5.50 Gained for 9router

I hadn’t checked upstream in a while. My fork had accumulated enough custom code — combos, middleware, the memory layer — that pulling updates felt like asking for trouble. Easier to stay isolated....

Aug 7, 2026

Debugging 9router Headroom: The Silent 404 That Masked a Version Mismatch

I found Headroom in a random LinkedIn post. It looked great — a compression proxy that sits between your app and the LLM providers, strips redundant context, saves tokens. The pitch was compelling....

Jul 31, 2026

The 403 That Wasn't: How Warp, 9router, and Cloudflare Masked Two Separate Failures

My Warp agent stopped talking to 9router. Quick background: 9router is my AI routing layer — a Hono server that proxies LLM requests through fallback providers. It runs locally via a Cloudflare tu...

© 2026 Alvian. Some rights reserved.

This blog is open source — view on GitHub

Using the Chirpy theme for Jekyll.

Trending Tags

9router technical tooling opencode postmortem ade ai personal warp agents