- Published on
opencode-9router: model discovery for OpenCode from a 9Router gateway

- Authors

- Name
- Nadim Tuhin
- @nadimtuhin
On this page
Every time my 9router gateway added a model, I hand-edited OpenCode's provider config. A static models block in opencode.json goes stale the first time a combo is added. opencode-9router is an OpenCode plugin that asks the gateway for the list instead.
The repo is nadimtuhin/opencode-9router. It is MIT licensed, package.json says version 1.0.0, and before the fix PR the history was a single commit from 2026-09-25.
Note: What 9Router is lives in 9Router: one local endpoint in front of every AI coding account I have. The launcher side is in 9agent.
The problem
OpenCode can talk to any OpenAI-compatible endpoint through @ai-sdk/openai-compatible. For a gateway you still have to declare each model, with its context window, output cap and flags for tools, vision and reasoning. A wrong limit means a wrong context or output budget for that model.
The README says the plugin replaces three existing bridges: @vheins/opencode-9router, @dendaio/opencode-9router-plugin and the 9Router bridge inside @benjamolina/pi-kit. The goal was one plugin that does discovery, caching and enrichment in one place.
How it works
The plugin uses OpenCode's config hook and edits the config object in place. The whole entry point is 55 lines in src/index.ts.

Which providers it touches
The plugin does not need a provider name. It takes every provider key that starts with 9router, so 9router, 9router-local and 9router-remote all work. If none exists it registers a default one:
// src/index.ts
const providerKeys = Object.keys(config.provider).filter((k) => k.startsWith("9router"));
if (providerKeys.length === 0) {
providerKeys.push("9router");
config.provider["9router"] = {
npm: "@ai-sdk/openai-compatible",
name: "9Router (Local)",
options: {
baseURL: "http://127.0.0.1:20128/v1"
}
};
}
Each provider is discovered inside Promise.allSettled, with its own try/catch. One dead gateway cannot block the others.
Fetch, timeout, key
src/discovery.ts normalizes the base URL so /v1, /v1/models and a bare host all land on /v1/models. The key comes from the provider options or NINEROUTER_API_KEY, and it is sent as a Bearer header unless it already starts with bearer . An AbortController cuts the request after discoveryTimeout, 10 seconds by default.
Cache with a stale fallback
The cache lives in ~/.cache/opencode-9router/. The file name is models- plus the first 16 hex characters of a SHA-256 of the base URL and API key, so two gateways or two keys never share a file. A fresh file (3 hours by default) skips the network entirely.
// src/cache.ts
writeFileSync(tmpPath, JSON.stringify({ timestamp: Date.now(), models }, null, 2));
renameSync(tmpPath, filePath);
Writing to a temp file and renaming means a second OpenCode process never reads a half-written cache. If the fetch fails and staleFallback is on, readStaleCache() returns the old list regardless of age. A failed write is swallowed on purpose.
Enrichment
A /v1/models response does not always carry limits. enrichModel() in src/enricher.ts walks a priority list for every field. For the context window it tries capabilities.contextWindow, then context_length, then a guess from the model id, then 128000.

In the version I first wrote, the guesses were plain substring checks (token-boundary matching later replaced the o1, o3, r1 and vl checks, see below):
// src/enricher.ts
let context = caps.contextWindow || m.context_length || 0;
if (!context) {
if (idLower.includes("gemini")) {
context = 1000000;
} else if (idLower.includes("claude")) {
context = 200000;
} else if (idLower.includes("gpt-4") || idLower.includes("o1") || idLower.includes("o3")) {
context = 128000;
} else if (idLower.includes("deepseek") || idLower.includes("qwen")) {
context = 131072;
} else if (m.owned_by === "combo" || idLower.includes("combo")) {
context = 262144;
} else {
context = 128000;
}
}
Tools, vision and reasoning follow the same order: explicit capability first, id heuristic second. A final clamp keeps context at 4096 or more and output at 1024 or more, so a bad value from the gateway cannot produce a zero limit.
Results
Numbers as of the first commit (2026-09-25):
- Version: 1.0.0 (
package.json) - Commits: 1 before the fix PR, dated 2026-09-25
- Source: 5 files, 339 lines in
src/ - Tests: 21 passing across 5 files (
bun test) when I wrote this - CI: GitHub Actions runs
bun install --frozen-lockfile,bun run build,bun test - Runtime dependencies: 0
- Stars: 0
After the fix PR: 26 tests (6/6/7/5/2), 341 lines.
The tests now cover the cache (6), discovery (6), enricher (7), the plugin hook (5) and an end-to-end file (2). That last file probes http://127.0.0.1:20128/v1/models with a 1 second timeout and now skips when no gateway answers.
The second test shells out to opencode models 9router. Both used to return early and count as passes without asserting anything, so the 21 included two tests that only meant something on a machine with a live gateway and OpenCode installed. They now report as skipped when the gateway or the OpenCode CLI is missing. The discovery tests use real local HTTP servers instead of mocks.
npm view opencode-9router returns a 404 today. The package is not on the npm registry, so the "plugin": ["opencode-9router"] line in the README will not resolve until it is published.
What I fixed after writing this
On 2026-10-14 I merged one PR that fixed six of the rough edges I found:
- Substring matching had no boundaries.
includes("o1"),includes("o3"),includes("r1")andincludes("vl")matched those two characters anywhere in the id, and the o1 and o3 checks also set the context and output guesses, so a false match changed limits as well as flags. Model matching now respects token boundaries. - Discovered models overwrote yours. The merge let the enriched guess beat a model you tuned by hand in
opencode.json. User models now win. - Failures were silent.
discoverModels()turned every fetch error into{}, so a wrong key, a 401 and a dead gateway all looked the same. Falling back now prints a warning. - Cache files ignored the API key. Their names came from the base URL alone, so two keys on one gateway read the same file. The cache key now includes the URL and a hash of the key.
- Two tests skipped silently. The e2e tests returned early and counted as passes. They now report as skipped when the gateway or the OpenCode CLI is missing.
getCachePath()created a directory. A function named like a getter had a side effect,writeCache()repeated the samemkdirSync, and the call atsrc/discovery.ts:19ran outside thetry, so a failure creating the directory was not covered by the fallback.
Takeaway
Every error path ends in a cached or empty list, so OpenCode always starts. The cost is visibility: a silent fallback needs one log line, or nobody knows it happened. That is the line I added.
It is not on npm yet. Clone the repo and point OpenCode's plugin entry at the local path, then open an issue with the model ids whose limits come out wrong.
Resources
Related posts
- express-simple-proxy: an Express proxy with hooks and typed errors
express-simple-proxy forwards Express routes to an upstream API with error codes, a beforeRequest hook and an onResponse callback. 183 tests, two runtime dependencies, and rough edges I found.
- devutils: 33 browser-only dev tools and what the README overstated
devutils runs JSON, JWT, SQL and Base64 tools in the browser with no network calls. I checked the README against the code: the privacy claim holds, the 35+ tool count did not.
- pmharden: auditing npm, pnpm and yarn config for secrets
pmharden audits package manager config, scans for plaintext tokens and flags risky global packages in one command. A walkthrough of the code, the 45 tests and the rough edges I still see.