OpenCode plugin that keeps only the OpenRouter models that can actually be served from a single data region (Europe by default).
When an OpenRouter API key is restricted to a data region, for example an organisation on the Business plan with allowed_data_regions: ["europe"], OpenRouter only accepts models that have an endpoint inside that region. Anything else fails at request time:
404 No endpoints found supporting your data region
There is no cross-region fallback, by design.
The correct fix is to point the provider at the in-region endpoint:
That moves the traffic, but not the model catalog: OpenCode builds the OpenRouter model list from models.dev, which is global and has no notion of region. The picker therefore still offers hundreds of models that will fail, and the documented whitelist / blacklist options are ignored by OpenCode V2 (packages/core/src/config/normalize.ts lists them as unsupported).
This plugin closes that gap. It reads GET {baseURL}/models; the in-region endpoint already returns only the models that region can serve (67 models at the time of writing, against 468 on the global endpoint), and removes every other OpenRouter model from the registry. The list is conservative: it omits OpenRouter's routers even when some of them can still answer in-region, see Known limitations.
- OpenCode V2 (developed and tested against 2.1.2)
- An OpenRouter account whose key is allowed in the target region
- The OpenRouter provider already connected:
opencode auth login openrouter - Network access to the in-region endpoint at least once; afterwards the catalog is cached
OpenCode V1 is not supported and does not need this plugin: V1 honours provider.whitelist natively. The V2 plugin contract (export default { id, setup } plus ctx.model.transform) does not exist in V1, so on V1 the plugin is simply never loaded.
Pick one.
From Git (no npm publishing needed):
opencode plugin add github:<owner>/opencode-openrouter-euFrom npm, once published:
opencode plugin add opencode-openrouter-euBy hand, as a local file:
mkdir -p ~/.config/opencode/plugins
curl -fsSL https://raw.githubusercontent.com/<owner>/opencode-openrouter-eu/main/dist/index.js \
-o ~/.config/opencode/plugins/openrouter-eu.jsThe plugin add forms record the plugin in the global opencode.json for you,
under the plugins key. The manual form needs no config entry: files in ~/.config/opencode/plugins/ are loaded automatically.
Then restart OpenCode.
opencode plugin listExpected: a row with id openrouter-eu.
opencode models | grep -c '^openrouter/'The number must match the in-region catalog:
curl -s https://eu.openrouter.ai/api/v1/models | jq '.data | length'Do not hardcode the expected count: it changes as OpenRouter adds models. What matters is that the two numbers agree.
Optional. Without a config file the defaults are used.
Default location: ~/.config/opencode/openrouter-eu.json
Override: OPENCODE_OPENROUTER_EU_CONFIG=/path/to/file.json
{
"providerID": "openrouter",
"baseURL": "https://eu.openrouter.ai/api/v1",
"ttlHours": 6,
"timeoutMs": 4000,
"onError": "open",
"allow": [],
"debug": false
}| Key | Default | Meaning |
|---|---|---|
providerID |
openrouter |
Provider whose model list is filtered |
baseURL |
the provider's own baseURL, else https://eu.openrouter.ai/api/v1 |
Endpoint used to read the regional catalog |
ttlHours |
6 |
How long a cached catalog stays valid |
timeoutMs |
4000 |
Timeout for one catalog request |
onError |
open |
No catalog available: open keeps every model, closed removes all but allow |
allow |
[] |
Model IDs that always survive, even if absent from the catalog |
debug |
false |
Verbose logging |
Unknown keys and invalid values produce a warning and are ignored; the plugin never fails to load because of configuration.
baseURLfrom the config file, when set;- otherwise the live
providers.<id>.settings.baseURLfrom the OpenCode registry; - otherwise
https://eu.openrouter.ai/api/v1.
If the resolved endpoint is OpenRouter's global one (https://openrouter.ai/api/v1), the plugin stays inert: filtering the global catalog would be wrong. If the endpoint the provider actually uses differs from the one the catalog was read from, the plugin logs a warning and does not filter, so it can never hide models that would have worked.
- Runs once per model registry load, never per request.
- Removes models only. It never adds, renames or reorders anything.
- A failed removal of one model is logged and does not stop the others.
- The catalog is cached at
$XDG_CACHE_HOME/opencode/openrouter-eu-models.json(mode0600). - Cache precedence: fresh cache, then network (one retry), then stale cache for the same endpoint, then the
onErrorpolicy. onError: "open"is the default because OpenRouter already refuses out-of-region models: a network hiccup costs accuracy, not compliance.
OpenRouter's routers are meta-models, and the in-region catalog does not list them, so the plugin removes them all. They do not all behave the same way:
openrouter/auto(Auto Router) is refused inside a data region with400;openrouter/freeandopenrouter/bodybuilderfail with404,openrouter/fusionwith403;openrouter/pareto-codeworks, and can be kept withallow.
The global endpoint is not a workaround: a region-restricted workspace rejects
global requests with 403.
See docs/known-limitations.md for the observed
responses, the allow example and the commands to verify the behaviour in your
own region.
| Symptom | Likely cause |
|---|---|
| Hundreds of OpenRouter models | Plugin not loaded: check opencode plugin list, restart the server, check --print-logs for openrouter-eu |
[openrouter-eu] ... no endpoint override |
The provider has no baseURL override, so it is using the global catalog |
[openrouter-eu] ... global endpoint |
The provider points at openrouter.ai instead of the in-region host |
[openrouter-eu] ... set baseURL in the plugin config |
Provider and catalog endpoints disagree; set baseURL explicitly |
[openrouter-eu] cannot load the regional catalog |
Network failure with an empty cache; with onError: open nothing is filtered |
[openrouter-eu] ctx.model.transform is not available |
Not OpenCode V2; on V1 use provider.whitelist instead |
Model unavailable: openrouter/auto |
Expected: the Auto Router is refused inside a data region. Pick a model explicitly, or allow-list openrouter/pareto-code if you want a router |
Model unavailable: openrouter/... |
Working as intended: the model is not served in this region |
Logs are printed by the OpenCode server; run opencode --print-logs or set OPENCODE_LOG_LEVEL=debug.
- The only outbound request is an unauthenticated
GET {baseURL}/models; no API key is sent, stored or logged by this plugin. - The cache contains model IDs and nothing else.
- The plugin does not alter request bodies or headers, so OpenRouter's own in-region enforcement remains the source of truth.
bun install
bun run typecheck # tsc --noEmit
bun test # unit tests, no network
bun run compile # dist/index.js, ESM bundle
bun run check # all of the aboveLayout:
| Path | Role |
|---|---|
src/index.ts |
plugin entry point and orchestration |
src/apply.ts |
pure filter over the model registry |
src/catalog.ts |
fetch, cache and TTL |
src/config.ts |
config loading and validation |
src/types.ts |
minimal typing of the V2 plugin contract |
test/ |
unit tests with injected fetch and fake editors |
The OpenCode V2 plugin types are not published on npm (@opencode-ai/plugin stops at 1.19.x, the V1 API), so src/types.ts declares only the surface this plugin uses, and every access is guarded at runtime.
To publish to npm: bun run compile && npm publish.
Note: the scripts are intentionally not named build, prepare or prepack.
OpenCode's compiled binary re-invokes itself as npm for the git-dependency
prepare step, so any of those lifecycle names makes opencode plugin add fail
with git dep preparation failed (upstream issue #49704). dist/ is committed
for the same reason.
MIT
{ "providers": { "openrouter": { "settings": { "baseURL": "https://eu.openrouter.ai/api/v1" } } } }