Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ events without requiring a restart.
See [Connect and Auth Documentation](docs/connect-and-auth.md) for credential
resolution, lifecycle handling, and security details.

`modelInfoFormat` and `smartModelName` are optional: without them, discovered names remain the model IDs and no external metadata is fetched. Supported metadata formats are `aiproxy`, `models.dev`, `bifrost`, `litellm`, `vllm`, `lmstudio`, `llama-swap`, and `omniroute`. `models.dev` fetches `https://models.dev/models.json` by default; `modelInfoEndpoint` can override the catalog URL. The `aiproxy` format composes that catalog with inline metadata from AIProxy's model list. `filterNonChat` defaults to `true`; it can filter non-chat models when the selected metadata source supplies that information. `timeoutMs` defaults to 5000 ms. Models returned by discovery are added alongside explicitly configured models.
`modelInfoFormat` and `smartModelName` are optional: without them, discovered names remain the model IDs and no external metadata is fetched. Supported metadata formats are `aiproxy`, `models.dev`, `bifrost`, `litellm`, `vllm`, `lmstudio`, `llama-swap`, `omniroute`, and `ollama`. `models.dev` fetches `https://models.dev/models.json` by default; `modelInfoEndpoint` can override the catalog URL. The `aiproxy` format composes that catalog with inline metadata from AIProxy's model list. `filterNonChat` defaults to `true`; it can filter non-chat models when the selected metadata source supplies that information. `timeoutMs` defaults to 5000 ms. Models returned by discovery are added alongside explicitly configured models.

For a local build, run `npm run compile` and replace the package value above with `"file:///absolute/path/to/opencode-models-discovery/dist"`. OpenCode v2 expects a **directory**, not a path to `index.js` or `server.js`. After rebuilding plugin code, run `opencode service restart` so the background service loads the new bundle. Discovery runs when the plugin initializes. The native TUI commands are `/models-discovery-refresh` and `/models-discovery-console`; the console combines Provider → model → model details browsing with cache inspection and clearing. Use `/models-discovery-console status --provider <provider-id>` to filter the browser. The OpenCode v2 agent tools `models_discovery_refresh` and `models_discovery_status` remain available for automation. Pass `rawCache: true` to `models_discovery_status` to include cached raw models and enrichment results.

Expand Down
35 changes: 32 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ Each provider can configure discovery behavior through `provider.<name>.options.
| `provider.<name>.options.modelsDiscovery.enabled` | `boolean` | Force enable or disable discovery for a single provider |
| `provider.<name>.options.modelsDiscovery.endpoint` | `string` | Provider-specific models endpoint as an origin-relative path beginning with `/`. Defaults to `/v1/models` |
| `provider.<name>.options.modelsDiscovery.timeoutMs` | positive finite `number` | Per-request timeout for the provider's models and provider-specific metadata endpoints. Defaults to `3000` |
| `provider.<name>.options.modelsDiscovery.modelInfoEndpoint` | `string` | Override a format-specific metadata endpoint as an origin-relative path or complete URL. Defaults to `/v1/model/info` for `"litellm"` and `/api/v1/models` for `"lmstudio"`; for `"aiproxy"`, it overrides the models.dev fallback URL |
| `provider.<name>.options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, and `"omniroute"` |
| `provider.<name>.options.modelsDiscovery.modelInfoEndpoint` | `string` | Override a format-specific metadata endpoint as an origin-relative path or complete URL. Defaults to `/v1/model/info` for `"litellm"`, `/api/v1/models` for `"lmstudio"`, and `/api/show` for `"ollama"`; for `"aiproxy"`, it overrides the models.dev fallback URL |
| `provider.<name>.options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, `"omniroute"`, and `"ollama"` |
| `provider.<name>.options.modelsDiscovery.filterNonChat` | `boolean` | When model info is available, skip models whose `model_info.mode` is not `chat`. Defaults to `true` |
| `provider.<name>.options.modelsDiscovery.models.includeRegex` | `string[]` | Shortcut regex allow-list for discovered model ids only |
| `provider.<name>.options.modelsDiscovery.models.excludeRegex` | `string[]` | Shortcut regex deny-list for discovered model ids only |
Expand Down Expand Up @@ -355,7 +355,7 @@ Community provider examples live in [`docs/config_example/`](config_example/).

The generic OpenAI-compatible `/v1/models` endpoint only guarantees a small model list shape. Extra metadata such as context limits, tool calling, reasoning, image input, or structured output is provider-specific, so metadata enrichment is opt-in.

The plugin currently supports eight model info formats:
The plugin currently supports nine model info formats:

| Format | Source | Requires `modelInfoEndpoint` | Notes |
|--------|--------|------------------------------|-------|
Expand All @@ -367,6 +367,7 @@ The plugin currently supports eight model info formats:
| `"lmstudio"` | LM Studio 0.4.0+ `/api/v1/models` inventory | No | Uses `/api/v1/models` by default; set `modelInfoEndpoint` for another path |
| `"llama-swap"` | Fields in llama-swap's `/v1/models` response | No | Reads inline context, modalities, and function-calling metadata when present |
| `"omniroute"` | Fields in OmniRoute's `/v1/models` response | No | Reads OmniRoute inline limits, modalities, and capabilities when present |
| `"ollama"` | Ollama native `POST /api/show` | No | Uses `/api/show` by default; set `modelInfoEndpoint` to override |

### AIProxy Metadata

Expand Down Expand Up @@ -604,6 +605,34 @@ Only models returned by `/v1/models` are injected. A model is enriched only when

When available, the plugin sets `limit.context` from the largest loaded instance `config.context_length`, otherwise it uses `max_context_length`. LM Studio does not report a distinct output limit, so the plugin resolves a safe default output limit bounded by the context window and capped at OpenCode's default 32,000 output tokens. The plugin maps `capabilities.vision` to image input and `capabilities.trained_for_tool_use` to `tool_call`. Its reported reasoning options are the source of truth for variants: `off`, `low`, `medium`, `high`, and `xhigh` become variants, with `off` sent as `reasoningEffort: "none"`; `on` and unknown options are omitted because they are not concrete OpenAI-compatible efforts. Missing or malformed metadata is left unset without preventing discovery.

### Ollama Model Show

Use `modelInfoFormat: "ollama"` with Ollama's OpenAI-compatible endpoint. Ollama's `GET /v1/models` response carries only the minimal model list shape, so the plugin enriches each discovered model with a native `POST /api/show` request (`{"model": "<id>"}`) after discovery. Set `modelInfoEndpoint` only when Ollama uses another show path.

```json
{
"plugin": ["opencode-models-discovery"],
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama",
"options": {
"baseURL": "http://localhost:11434/v1",
"modelsDiscovery": {
"enabled": true,
"modelInfoFormat": "ollama"
}
},
"models": {}
}
}
}
```

Only models returned by `/v1/models` are injected, and each one receives its own `/api/show` request in parallel. Each model is enriched from the `/api/show` response stored under the exact id the plugin requested; a failed or malformed per-model response degrades to no enrichment for that model without preventing discovery.

The context limit is resolved in strict order: a valid `num_ctx` value parsed from the `parameters` string first — a `num_ctx` set in the model's Modelfile is Ollama's only intentional effective-context signal, so it wins over the architecture-derived maxima — then `model_info.<details.family>.context_length`, then any other `model_info` key ending in `.context_length`, then a top-level `context_length`. A missing or non-positive `num_ctx` is ignored and the chain applies; the pinned value is not clamped to the architecture maximum. The output limit is bounded by the context window and capped at OpenCode's default 32,000 output tokens. Ollama `capabilities` map as follows: `vision` or `images` add image input, `audio` adds audio input, `thinking` sets `reasoning`, and `tools`, `function_calling`, or `tool_calling` set `tool_call`. Unknown capabilities and missing fields are ignored without inventing metadata. `filterNonChat` has no effect for this format; Ollama's show response exposes no chat-mode field.

### models.dev Metadata

Use `modelInfoFormat: "models.dev"` to enrich discovered models from the public [models.dev](https://models.dev) metadata index.
Expand Down
28 changes: 28 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,31 @@ This project is not affiliated with, endorsed by, or sponsored by [models.dev](h
When a safe model id segment match exists, models.dev metadata can add model limits and capabilities such as `tool_call`, `reasoning`, `attachment`, `structured_output`, `temperature`, and `modalities`.

This means providers using `@ai-sdk/anthropic` with OpenAI-compatible backends are supported when the `baseURL` contains `/v1/`, when a provider-specific discovery endpoint is configured, or when provider-level discovery is explicitly forced on. It also means providers like DeepSeek can be discovered from a non-`/v1` baseURL as long as the models endpoint is configured explicitly.

### Ollama Metadata Enrichment

Ollama exposes an OpenAI-compatible `GET /v1/models` endpoint that returns only the minimal model list shape — no context limits and no capabilities. Ollama's native API provides both through `POST /api/show` with a `{"model": "<id>"}` body, so the plugin supports an explicit Ollama metadata format:

```json
{
"plugin": ["opencode-models-discovery"],
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama",
"options": {
"baseURL": "http://localhost:11434/v1",
"modelsDiscovery": {
"enabled": true,
"modelInfoFormat": "ollama"
}
},
"models": {}
}
}
}
```

This setting is explicit because the plugin issues one additional `POST /api/show` request per discovered model after the `/v1/models` fetch. Without `modelInfoFormat: "ollama"`, the plugin does not contact `/api/show`.

For each discovered model, the plugin resolves the context limit from a valid `num_ctx` in the show body's `parameters` string first (the Modelfile's explicit context pin; missing or non-positive values are ignored), falling back to `model_info.<family>.context_length`, then any other `*.context_length` key, then a top-level `context_length`; and maps Ollama `capabilities` to modalities, `reasoning`, and `tool_call`. A per-model failure degrades to no enrichment for that model only; discovery continues for the rest.
61 changes: 61 additions & 0 deletions src/core/enricher-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { fetchModelsDevData, DEFAULT_MODELS_DEV_URL } from '../utils/models-dev-

export const DEFAULT_LITELLM_ENDPOINT = '/v1/model/info'
export const DEFAULT_LMSTUDIO_ENDPOINT = '/api/v1/models'
export const DEFAULT_OLLAMA_ENDPOINT = '/api/show'
export const DEFAULT_ENRICHER_TIMEOUT_MS = 5000

export interface ModelInfoResolutionOptions {
readonly format?: ModelInfoFormat | string
readonly endpoint?: string
readonly filterNonChat?: boolean
readonly timeoutMs?: number
readonly modelIds?: readonly string[]
}

export interface ModelInfoResolverLogger {
Expand Down Expand Up @@ -78,6 +80,65 @@ export async function resolveModelInfoEnricher(
return createModelEnricher(format, null)
}

if (format === ModelInfoFormat.Ollama) {
const modelIds = options.modelIds ?? []
if (modelIds.length === 0) {
context.logger?.warn('Ollama model info format requires model IDs', {
provider: context.providerName,
})
return undefined
}

const targetEndpoint = options.endpoint ?? DEFAULT_OLLAMA_ENDPOINT
const showUrl = buildModelInfoURL(context.baseURL, targetEndpoint)
const fetcher = context.fetcher ?? globalThis.fetch
const timeoutMs = options.timeoutMs ?? DEFAULT_ENRICHER_TIMEOUT_MS

const headers = new Headers({ accept: 'application/json', 'content-type': 'application/json' })
if (context.apiKey) {
headers.set('authorization', `Bearer ${context.apiKey}`)
}

const results = await Promise.all(modelIds.map(async (modelId): Promise<readonly [string, unknown] | undefined> => {
try {
const res = await fetcher(showUrl, {
method: 'POST',
headers,
body: JSON.stringify({ model: modelId }),
signal: AbortSignal.timeout(timeoutMs),
})
if (!res.ok) {
context.logger?.warn('Provider model info discovery failed', {
provider: context.providerName,
endpoint: targetEndpoint,
format,
modelId,
status: res.status,
})
return undefined
}
return [modelId, await res.json()] as const
} catch (error) {
context.logger?.warn('Provider model info discovery failed', {
provider: context.providerName,
endpoint: targetEndpoint,
format,
modelId,
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}))

const shows = new Map<string, unknown>()
for (const entry of results) {
if (entry) shows.set(entry[0], entry[1])
}
if (shows.size === 0) return undefined

return createModelEnricher(format, shows, { filterNonChat })
}

if (format === ModelInfoFormat.LiteLLM || format === ModelInfoFormat.LMStudio) {
const defaultEndpoint = format === ModelInfoFormat.LiteLLM ? DEFAULT_LITELLM_ENDPOINT : DEFAULT_LMSTUDIO_ENDPOINT
const targetEndpoint = options.endpoint ?? defaultEndpoint
Expand Down
2 changes: 2 additions & 0 deletions src/plugin/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ Supported plugin options under provider.<id>.options.modelsDiscovery:
- modelInfoFormat="lmstudio": discover callable models through the normal /v1/models endpoint, then enrich exact model-id matches from LM Studio's /api/v1/models inventory
- modelInfoFormat="llama-swap": read llama-swap's inline /v1/models context, modalities, and function-calling metadata without another request
- modelInfoFormat="omniroute": read OmniRoute's documented inline /v1/models limits, modalities, and capabilities without another request
- modelInfoFormat="ollama": enrich exact model-id matches from Ollama's POST /api/show endpoint; modelInfoEndpoint optionally overrides the path
- filterNonChat: when LiteLLM model info is available, skip non-chat models by default
- cache.enabled: opt in to a provider-scoped persisted discovery cache of filtered and enriched model configurations; defaults to false
- cache.ttlSeconds: non-negative finite cache lifetime in seconds; defaults to 86400
Expand Down Expand Up @@ -153,6 +154,7 @@ Recommended defaults:
- use modelInfoFormat="lmstudio" for LM Studio REST metadata enrichment; set modelInfoEndpoint only when LM Studio uses another inventory path
- use modelInfoFormat="llama-swap" only for llama-swap /v1/models responses; it is an explicit inline-metadata format and does not make another request
- use modelInfoFormat="omniroute" only for OmniRoute's documented /v1/models response; it is an explicit inline-metadata format and does not make another request
- use modelInfoFormat="ollama" for Ollama-native metadata enrichment; set modelInfoEndpoint only when Ollama uses another path
- configure caching only when the user requests it; it is disabled by default
- the persisted discovery cache belongs to this plugin and is not a replacement for opencode.json

Expand Down
1 change: 1 addition & 0 deletions src/plugin/enhance-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ export async function enhanceConfig(
endpoint: providerDiscoveryConfig.modelInfoEndpoint,
filterNonChat,
timeoutMs,
modelIds: models.filter(isDiscoveredRawModel).map((model) => model.id),
})
}
if (reprocessPersistedModels) {
Expand Down
1 change: 1 addition & 0 deletions src/types/plugin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
LMStudio = 'lmstudio',
LlamaSwap = 'llama-swap',
OmniRoute = 'omniroute',
Ollama = 'ollama',
}

export const DEFAULT_CACHE_TTL_SECONDS = 86400
Expand Down Expand Up @@ -104,7 +105,7 @@
fallback: DEFAULT_DISCOVERY_CONFIG.enabled,
})
} else {
console.warn(`[opencode-models-discovery] Ignoring invalid OPENCODE_MODELS_DISCOVERY_DEFAULT_ENABLED value: ${rawValue}`)

Check warning on line 108 in src/types/plugin-config.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected console statement
}

return DEFAULT_DISCOVERY_CONFIG
Expand Down Expand Up @@ -141,7 +142,7 @@
if (logger) {
logger.warn('Ignoring invalid model regex', { category: 'filtering', pattern })
} else {
console.warn(`[opencode-models-discovery] Ignoring invalid model regex: ${pattern}`)

Check warning on line 145 in src/types/plugin-config.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected console statement
}
return null
}
Expand All @@ -165,7 +166,7 @@
if (logger) {
logger.warn('Ignoring invalid model field regex', { category: 'filtering', field: filter.field, pattern: filter.match })
} else {
console.warn(`[opencode-models-discovery] Ignoring invalid model field regex for ${filter.field}: ${filter.match}`)

Check warning on line 169 in src/types/plugin-config.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected console statement
}
return null
}
Expand Down
2 changes: 2 additions & 0 deletions src/utils/model-info/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createBifrostEnricher } from './bifrost'
import { createLiteLLMEnricher } from './litellm'
import { createLMStudioEnricher } from './lmstudio'
import { createOllamaEnricher } from './ollama'
import { createLlamaSwapEnricher } from './llamaswap'
import { createModelsDevEnricher } from './models-dev'
import { createOmniRouteEnricher } from './omniroute'
Expand All @@ -20,6 +21,7 @@ const MODEL_ENRICHERS: Partial<Record<ModelInfoFormat, ModelEnricherFactory>> =
[ModelInfoFormat.LMStudio]: (data) => createLMStudioEnricher(data),
[ModelInfoFormat.LiteLLM]: (data) => createLiteLLMEnricher(data),
[ModelInfoFormat.AIProxy]: (data) => createAIProxyEnricher(data),
[ModelInfoFormat.Ollama]: (data) => createOllamaEnricher(data),
}

/** Creates the neutral contract used by the shared discovery pipeline. */
Expand Down
Loading
Loading