Real and nasty precisely because it is silent: `z.string().url()` accepts `localhost:20128` as scheme `localhost:` plus a path, every model gets published with an unusable api url, and the failure happens inside the client so the gateway logs show nothing. Backing the option schema, the publish boundary and the snapshot filter with one `isHttpUrl` in v2 is the right call — those three cannot drift apart. Duplicating the predicate in v1 rather than sharing it is also correct, since the two packages ship independently. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them. - `typecheck:core` clean; `check:changelog-integrity` OK - complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline - 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests - `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs. Thanks @maxmad64bis.
8.1 KiB
title, version, lastUpdated
| title | version | lastUpdated |
|---|---|---|
| OpenCode v2 plugin — install and configure | 3.8.51 | 2026-09-06 |
OpenCode v2 plugin — install and configure
@omniroute/opencode-plugin-v2 puts your whole OmniRoute catalog — models, combos and
auto-combos — into OpenCode v2's model picker, with display names, pricing and free-tier
budgets.
It is a separate package from @omniroute/opencode-plugin because OpenCode v1 and v2 load
plugins through different contracts. Pick the one matching your OpenCode major; nothing is
shared between them, so upgrading one never forces the other.
Requirements
- OpenCode v2.
- A reachable OmniRoute gateway (
http://localhost:20128by default). - Node.js 22 or 24.
Install
Add the plugin to opencode.json:
{
"plugins": [
{
"package": "@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
Models then appear as omniroute/<provider>/<model>; providerId decides that prefix and
the id of the integration OpenCode stores the credential under.
Credentials
The plugin looks for a gateway key in three places, in this order:
- The credential you connected in OpenCode. The plugin registers an integration, so
OpenCode's own auth flow can store the key. Nothing lands in
opencode.json— prefer this. apiKeyin the plugin options, for a per-project override. It puts the key in a file you may be committing.OMNIROUTE_API_KEYin the environment.
With none of the three, the catalog is empty and the plugin says so once at startup instead of leaving you with a silent empty picker.
The management token is a different key
Combos, provider health and enrichment (display names, pricing, free-tier budgets) come from
the gateway's /api/* endpoints, which most deployments gate behind a management token
rather than the inference key:
"options": {
"baseURL": "http://localhost:20128",
"managementReadToken": "<management read token>"
}
Left unset, managementReadToken falls back to apiKey. When a gateway rejects that fallback
the catalog still publishes, but with raw model ids instead of display names, no canonical
alias dedupe, no pricing and no combos. The plugin warns once per endpoint when that happens,
naming the endpoint and what was lost — so a degraded picker is never a mystery.
Options
| Key | Default | Notes |
|---|---|---|
providerId |
"omniroute" |
Provider id, integration id, and the prefix models appear under |
baseURL |
required | Gateway root, http(s) only; the /v1 suffix is added where needed |
apiKey |
connected credential, then OMNIROUTE_API_KEY |
Chat key for /v1/* |
managementReadToken |
falls back to apiKey |
Key for /api/* — usually not the same one |
displayName |
"OmniRoute" |
Provider name in the picker |
timeoutMs |
10000 |
Per-endpoint fetch timeout (auto-combos use 5s) |
modelCacheTtlMs |
300000 |
Catalog cache TTL; a disk snapshot warms cold starts |
timeouts |
falls back to timeoutMs |
Per-endpoint budgets in ms: models, combos, autoCombos, enrichment |
enrichment |
true |
Fetch names, pricing and free-tier budgets |
providerTag |
true |
Prefix a display name with the upstream provider it routes to |
usableOnly |
false |
Keep only providers the gateway reports as provisioned |
visibleModels / hiddenModels |
[] |
Exact-or-suffix allowlists; deny wins |
geminiSanitization |
true |
Strip the JSON-Schema keywords Gemini rejects from tool schemas ($ref tools are forwarded untouched) |
apiFormat.allowAnthropic |
false |
Route allowlisted ids through the Anthropic API block |
apiFormat.anthropicModels |
[] |
Full model ids routed to Anthropic |
logLevel / startupDebug |
warn / false |
Logger verbosity |
How the catalog stays fresh
The catalog is fetched lazily and cached for modelCacheTtlMs, and a disk snapshot keeps the
last known catalog available when the gateway is unreachable — an outage costs you nothing but
freshness. Models and combos are published as soon as they arrive; auto-combos, the provider
list and the enrichment overlay fold in when they land, so one slow endpoint cannot hold the
whole picker hostage.
Names carry what the gateway knows: the upstream provider a model routes to, a [Free] marker
and the budget that comes with it — so two connections selling the same model stay
distinguishable. Turn the prefix off with "providerTag": false.
There is no manual refresh command: OpenCode v2 commands are prompt templates, not callbacks, so a plugin cannot expose one. Refreshes follow the TTL; the host is asked to reload only when the catalog or the overlay actually changed, never once per refresh.
Tool calling on Gemini
Gemini rejects an entire request whose tool declarations carry $schema or
additionalProperties, answering 400 INVALID_ARGUMENT. The plugin strips those keywords from
tool schemas bound for a Gemini model of this provider and leaves every other request untouched.
A tool carrying a $ref is forwarded untouched rather than stripped, since removing the
reference would widen the schema to "accept anything".
Set "geminiSanitization": false to turn it off.
Troubleshooting
| Symptom | Cause |
|---|---|
| Picker shows no OmniRoute model | No key resolved (check the startup warning), or the gateway is unreachable |
| Raw model ids, no combos, no pricing | The management endpoints refused the token — set managementReadToken |
A session pinned to opencode-<id>/… |
The v1 plugin published opencode-<id>; v2 publishes <id> bare, so re-select the model under <id>/… |
See also
- CLI-INTEGRATIONS.md — every
setup-*CLI integration, including the lightweight openai-compatible OpenCode setup. - REMOTE-MODE.md — pointing a CLI at a remote gateway.