OpenCode plugin that enriches model entries already contributed by other plugins (or by your opencode.json) with full metadata — context length, output limit, pricing, modalities, and capability flags (tool_call, reasoning, attachment) — by fetching from a provider-supplied OpenRouter-shaped endpoint.
Auth-agnostic by design: the plugin runs as an OpenCode config hook after other plugins have populated providers and headers, so it composes with @vymalo/opencode-oauth2, static API keys, or any other auth scheme without depending on any of them.
OpenCode supports rich per-model metadata (context window, USD/M-token cost, tool-call/reasoning/attachment flags) but you usually have to handwrite it in opencode.json. If your provider exposes a JSON endpoint with this info (OpenRouter, LiteLLM with the OpenRouter-compat extension, your own gateway), this plugin fetches it once, merges it onto every model, caches the result, and stays out of the way.
npm install @vymalo/opencode-models-infoAdd it to your opencode.json plugin list:
{
"plugin": ["@vymalo/opencode-models-info"]
}meta.modelsInfoUrl is the HTTP(S) endpoint that returns the metadata JSON — an absolute URL or a path resolved against options.baseURL. Point it at your own provider's metadata endpoint:
{
"plugin": ["@vymalo/opencode-models-info"],
"provider": {
"my-provider": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.example.com/v1",
"meta": {
"modelsInfoUrl": "https://api.example.com/v1/models",
"modelsInfoTtlSeconds": 86400,
"modelsInfoTimeoutMs": 5000
}
},
"models": { "my-model-large": {} }
}
}
}An absolute URL is clearest. A relative path is also accepted — it resolves against baseURL (e.g. "models" → https://api.example.com/v1/models); see URL resolution.
What shape must that endpoint return? The JSON described in Expected response shape below — commonly called the OpenRouter shape because OpenRouter's
/modelsendpoint returns it, but the plugin has no dependency on OpenRouter and never contacts it. The compatibility bar is low: a bare top-level array (nodatawrapper) is accepted, and the mapping is partial, so your endpoint only needs to emit the fields you want enriched (e.g. justid+context_length+pricing). But note: a vanilla OpenAI-compatible/v1/modelsreturns onlyid/object/owned_by— none of the fields this plugin maps — so pointingmodelsInfoUrlthere fetches successfully and enriches nothing. The endpoint has to actually carry the richer data.
That's it. After OpenCode starts:
- The hook picks up every provider with a
meta.modelsInfoUrl. - It
GETs that URL once, sending whateveroptions.headersthe provider already has (so it composes with any auth plugin — see Auth composition). - Each model entry whose
idmatches an entry in the response getslimit,cost,modalities,tool_call,reasoning,attachment, etc. filled in — only where they were not already set (upstream wins). - The response is cached on disk for
modelsInfoTtlSeconds(default 24h), keyed by(providerId, url, modelsInfoHeaders). ETags are honored. - On fetch error with a valid cache, the stale snapshot is served — the plugin never blocks OpenCode startup on a network failure.
meta.modelsInfoUrl resolves against options.baseURL using standard WHATWG URL semantics:
baseURL |
modelsInfoUrl |
Resolved URL |
|---|---|---|
https://x.test/v1 |
models/info |
https://x.test/v1/models/info |
https://x.test/v1 |
/models/info |
https://x.test/models/info |
https://x.test/v1 |
https://o.test/m |
https://o.test/m |
Two practical rules: drop the leading / to keep the metadata path under your inference API path; keep the leading / to escape to a different path under the same host.
| Option | Default | Notes |
|---|---|---|
meta.modelsInfoUrl |
(required) | Absolute URL or path resolved against options.baseURL (see above). |
meta.modelsInfoTtlSeconds |
86400 (24h) |
Cache TTL. |
meta.modelsInfoTimeoutMs |
5000 |
Per-fetch HTTP timeout. |
meta.modelsInfoHeaders |
(none) | Extra request headers. Override options.headers on conflict. Included in the cache key, so a tenant switch busts the cache. |
The plugin sends the union of options.headers and meta.modelsInfoHeaders (meta wins on conflict). This makes three common setups work without configuration:
- Public metadata endpoint (e.g. OpenRouter's
/models) — no auth needed. - Static API key — drop a
Bearerintooptions.headersonce, both inference and metadata use it. - OAuth2 via
@vymalo/opencode-oauth2≥ 0.4.0 — that plugin stamps the cached bearer intooptions.headers.Authorizationat config time so the metadata fetch inherits it automatically. The chat-time path still uses freshly-refreshed tokens.
If you need a different token for the metadata endpoint than for inference (e.g. a service-account bearer), set it explicitly under meta.modelsInfoHeaders.Authorization — it'll override whatever the provider has set.
List the oauth2 plugin first so its config hook runs before this one — that's what puts the bearer on options.headers in time for the metadata fetch:
oauth2 authenticates the provider and discovers its models; this plugin then fetches modelsInfoUrl using the token oauth2 stamped onto the provider headers, and enriches those discovered models. No models block and no Authorization header to manage — both are handled for you.
{
"data": [
{
"id": "model-a",
"name": "Model A",
"context_length": 128000,
"pricing": { "prompt": "0.000003", "completion": "0.000015" },
"architecture": { "input_modalities": ["text", "image"], "output_modalities": ["text"] },
"top_provider": { "max_completion_tokens": 4096 },
"supported_parameters": ["tools", "temperature", "reasoning"]
}
]
}A bare top-level array (no data wrapper) is also accepted.
| OpenRouter | OpenCode |
|---|---|
context_length + top_provider.max_completion_tokens |
limit.context / limit.output |
pricing.prompt / .completion (USD/token) |
cost.input / cost.output (USD per 1M tokens — converted) |
pricing.input_cache_read / .input_cache_write |
cost.cache_read / cost.cache_write |
architecture.input_modalities / .output_modalities |
modalities.input / modalities.output (filtered to OpenCode's enum) |
supported_parameters: ["tools" or "tool_choice"] |
tool_call: true |
supported_parameters: ["reasoning" / "thinking" / …] |
reasoning: true |
supported_parameters: ["temperature"] |
temperature: true |
| Non-text input modality present | attachment: true |
name |
name (if absent) |
| OS | Path |
|---|---|
| macOS | ~/Library/Caches/opencode-models-info/ |
| Linux | ${XDG_CACHE_HOME:-~/.cache}/opencode-models-info/ |
| Windows | %LOCALAPPDATA%\opencode-models-info\ |
Files are named by sha256(providerId::url), 0o600, atomic-rename-on-write.
Unit tests run against mocked fetch:
pnpm --filter @vymalo/opencode-models-info testIntegration tests run against a real HTTP server (WireMock) from the workspace's shared test-env/ compose stack. They skip themselves when INTEGRATION_MODELS_INFO_URL is unset:
pnpm test:env:up # from repo root
pnpm --filter @vymalo/opencode-models-info test:integration
pnpm test:env:down
# Or one-shot from repo root: spin up, run all integration suites, tear down.
pnpm test:integrationThe integration suite exercises real network round-trips, ETag handling (304 Not Modified), modelsInfoHeaders propagation, and the disk cache — all against a fixed catalog fixture under test-env/wiremock/__files/openrouter-catalog.json.
For embedding the enrichment logic outside an OpenCode hook (e.g. tests or custom tooling), import from the /lib subpath:
import { enrichConfig, FileCacheStore, createJsonConsoleLogger } from "@vymalo/opencode-models-info/lib";MIT
{ "plugin": ["@vymalo/opencode-oauth2", "@vymalo/opencode-models-info"], "provider": { "my-provider": { "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://api.example.com/v1", "oauth2": { "issuer": "https://auth.example.com", "clientId": "opencode-client", "scopes": ["openid", "profile", "offline_access"] }, "meta": { "modelsInfoUrl": "https://api.example.com/v1/models" } } } } }