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, 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.
- A background scheduler then keeps re-checking the endpoint on that same
modelsInfoTtlSecondscadence for as long as the process runs — see Periodic refresh.
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.
config — the only hook this plugin uses — runs once when OpenCode boots the plugin, and only re-runs on certain config edits (not on a timer). For a short-lived CLI invocation that's the whole story; but for a long-lived process (a desktop app window, or an embedded server that stays up for days), that hook would otherwise only ever see the catalog as it looked at boot.
To close that gap, every opted-in provider also gets a background scheduler that keeps re-fetching modelsInfoUrl — unconditionally (still cheap, via a conditional ETag request) — on the same meta.modelsInfoTtlSeconds cadence that already governs cache freshness. There's no separate interval to configure: set modelsInfoTtlSeconds once and it controls both "how stale can the cache be" and "how often do we go check." Default is once a day (86400).
This warms the cache for the next config run (the next launch, the next window, or the next config-triggered rebuild) — it can't push an update into an already-open session, since a config hook has no way to hot-patch a running one; there's simply no live channel for that in OpenCode's plugin API today. A failed check backs off (capped at the configured interval) instead of hammering a down endpoint, and resumes the normal cadence once a check succeeds. The scheduler is stopped and restarted whenever config reruns (so a rebuilt config never leaks a duplicate timer), and stopped for good on dispose.
| Option | Default | Notes |
|---|---|---|
meta.modelsInfoUrl |
(required) | Absolute URL or path resolved against options.baseURL (see above). |
meta.modelsInfoTtlSeconds |
86400 (24h) |
Cache TTL — also the background refresh interval, see Periodic refresh. |
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. |
meta.modelsInfoOverwrite |
(none) | Array of field names (name, attachment, reasoning, temperature, tool_call, cost, limit, modalities) that the endpoint is allowed to overwrite even when already set. Opts those fields out of upstream-wins — see Forcing endpoint values to win. Unknown names are ignored. |
meta.modelsInfoHideTextOnly |
false |
When true, makes the modelsInfoUrl catalog authoritative for which models are shown: drops a model from provider.models entirely if the catalog reports it as text-in/text-out only, or if the catalog doesn't mention it at all — see Hiding text-only models. |
meta.modelsInfoHideInternal |
false |
When true, drops a model from provider.models if the catalog reports a non-standard internal: true field for it. Independent of modelsInfoHideTextOnly — modality and internal/restricted status are unrelated signals — see Hiding internal models. |
meta.modelsInfoHideUnmatched |
false |
When true, drops a model from provider.models if the catalog has no entry for it at all — the membership half of modelsInfoHideTextOnly, without its modality filtering. Either flag alone triggers the same deletion — see Requiring a catalog entry. |
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.
The displayed name. oauth2's discovery stamps a normalized
nameonto each model (e.g.kimi-k2.6→Kimi K2.6). Because the merge is upstream-wins, that pre-set name blocks the endpoint's ownnamefrom landing — so the UI shows the normalized label, not the one your metadata endpoint returns. Add"modelsInfoOverwrite": ["name"](next section) to let the endpoint name win.
The merge is upstream-wins by default: a field already present on a model entry is never overwritten, so a handwritten opencode.json always takes precedence. But an upstream value isn't always handwritten — another plugin may auto-stamp one. The clearest case is @vymalo/opencode-oauth2, which writes a normalized name onto every discovered model; to upstream-wins that's indistinguishable from a deliberate user value, so the endpoint's name can never replace it.
meta.modelsInfoOverwrite opts specific fields out of upstream-wins:
{
"options": {
"meta": {
"modelsInfoUrl": "models/info",
"modelsInfoOverwrite": ["name"] // endpoint name beats oauth2's normalized one
}
}
}Listed fields still only change when the endpoint actually provides a value (a missing field never blanks an existing one), and unlisted fields keep the default upstream-wins behavior. Valid names are the mapped fields: name, attachment, reasoning, temperature, tool_call, cost, limit, modalities.
Forcing a capability flag off. The boolean flags (
tool_call,reasoning,temperature,attachment) are normally emitted true-only. Listing one inmodelsInfoOverwritealso lets the endpoint assertfalse(to clear a staletrue) — but only when the endpoint actually reports the underlying data (supported_parameters, orarchitecture.input_modalitiesforattachment). If that data is absent the plugin can't tell true from false, so it leaves the field alone rather than inventing afalse.
Some catalogs mix chat-only models in with multimodal ones, and you may only want to offer the multimodal set (vision, audio, etc.) in OpenCode's model picker. Set meta.modelsInfoHideTextOnly: true to make the modelsInfoUrl catalog authoritative for which models exist, not just their metadata:
{
"options": {
"meta": {
"modelsInfoUrl": "models/info",
"modelsInfoHideTextOnly": true
}
}
}With the flag on, a model's entry is deleted from provider.models outright in either of two cases:
- The catalog reports it as text-only —
architecture.input_modalities/.output_modalitiesare both present and resolve to exactly["text"]. - The catalog doesn't mention it at all — no entry in the
modelsInfoUrlresponse matches the model'sid(or declaredid). This is what makes the catalog take total precedence over whatever populatedprovider.modelsfirst (a staticopencode.json, or@vymalo/opencode-oauth2's own/v1/modelsdiscovery) — anything the richer catalog doesn't vouch for is dropped, not just left un-enriched.
This is a hard delete, not a flag — a hidden model can't be selected at all, the same as if it had never been listed in models to begin with. It never adds a model the catalog knows about but discovery/config didn't already list — only membership already present in provider.models can be pruned. Case 1 only fires when the endpoint's modality data is actually present; a model the plugin can't classify (missing architecture) but that is matched in the catalog is left alone rather than hidden on a guess. Case 2 fires purely on absence from the catalog, independent of modality data. Both cases log at debug (models_info_model_hidden_text_only, models_info_model_hidden_unmatched) and are reflected in the hiddenCount on the summary models_info_enriched event.
Composes with
modelsInfoOverwrite. Turning onmodelsInfoHideTextOnlydoesn't change how surviving models are merged — upstream-wins (or yourmodelsInfoOverwritelist) still governs individual fields. It only changes which model entries survive to be merged at all.
Case 2 above — dropping a model the catalog doesn't mention at all — is useful on its own, without the modality filtering that comes bundled into modelsInfoHideTextOnly. Set meta.modelsInfoHideUnmatched: true to get just that:
{
"options": {
"meta": {
"modelsInfoUrl": "models/info",
"modelsInfoHideUnmatched": true
}
}
}This exists because a real adopter needed catalog-authoritative membership — a client's provider.models can carry ids the catalog has since dropped (renamed, removed, access-scoped, or just stale local state a client picked up a while ago) — but modelsInfoHideTextOnly's modality filtering was hiding legitimate text-only external models right along with the stale ones.
- A model with no catalog entry is hidden, regardless of modality.
- A matched text-only model is left alone by this flag on its own (it's still subject to
modelsInfoHideTextOnly's modality check if that flag is also set). modelsInfoHideTextOnlyandmodelsInfoHideUnmatchedreach the same deletion for an unmatched model — either alone is enough, setting both is redundant, not additive.
Don't reach for modelsInfoHideTextOnly just to get this behavior if you don't also want modality filtering — that's exactly the trap modelsInfoHideInternal was built to avoid for the internal/access-scope axis (see below). Logs at debug (models_info_model_hidden_unmatched, shared with modelsInfoHideTextOnly's own unmatched-hiding).
If your catalog serves both externally-usable and internal-only/restricted models — models that exist on the backend but shouldn't be selectable in OpenCode — flag them explicitly and set meta.modelsInfoHideInternal: true:
{
"options": {
"meta": {
"modelsInfoUrl": "models/info",
"modelsInfoHideInternal": true
}
}
}Your catalog entry adds a non-standard internal: true field:
{ "id": "internal-only-model", "internal": true, "architecture": { "input_modalities": ["text", "image"], "output_modalities": ["text"] } }Don't reach for modelsInfoHideTextOnly to hide internal models — it was tempting because a catalog's internal models are sometimes also text-only, but that's a coincidence of your catalog, not a rule: it hides a matched model based on modality, so a legitimate text-only external model gets hidden right along with your internal ones, and a multimodal internal model sails through unhidden. modelsInfoHideInternal reacts only to the internal field — set it independently:
internal: true+modelsInfoHideInternal: true→ hidden, regardless of modality.internalabsent orfalse→ never hidden by this flag, regardless of modality.- Unknown (
internalfield absent from the catalog entry) is left alone, not treated asfalse— same "known before we assert" rule as everywhere else in this plugin.
All three flags compose freely; set whichever your catalog's semantics call for. Note that modelsInfoHideInternal does not extend case 2 above (dropping a model your catalog has no entry for at all) — that's governed by modelsInfoHideTextOnly or modelsInfoHideUnmatched (see above), since an unmatched model's status is unknown, not "internal." Logs at debug (models_info_model_hidden_internal).
{
"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" } } } } }