Skip to content
Merged
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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Add the plugin package to your OpenCode v2 `opencode.json` (or `opencode.jsonc`)
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"gateway-custom": {
Expand Down Expand Up @@ -80,7 +80,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`, `omniroute`, and `openrouter`. `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. The `openrouter` format reads OpenRouter's inline `/v1/models` metadata without another request. `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`, `openrouter`, 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. The `openrouter` format reads OpenRouter's inline `/v1/models` metadata without another request. The `ollama` format reads per-model context lengths and capabilities from Ollama's native `/api/show` endpoint. `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 All @@ -103,7 +103,7 @@ For OpenCode v1 (1.18.29+), add the plugin to your `opencode.json`:
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-models-discovery@latest"
"opencode-models-discovery@2.1.0"
],
"provider": {
"lmstudio": {
Expand Down Expand Up @@ -336,6 +336,17 @@ For LM Studio 0.4.0+'s native v1 REST inventory endpoint:
}
```

For Ollama's native `/api/show` per-model metadata:

```json
{
"modelsDiscovery": {
"enabled": true,
"modelInfoFormat": "ollama"
}
}
```

For vLLM's inline `max_model_len` metadata, without another metadata request:

```json
Expand Down
2 changes: 1 addition & 1 deletion docs/config_example/a2agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This project is not affiliated with, endorsed by, or sponsored by [A2Agent](http
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"a2agent": {
Expand Down
2 changes: 1 addition & 1 deletion docs/config_example/ai-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This project is not affiliated with, endorsed by, or sponsored by [AI-ROUTER](ht
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"ai-router": {
Expand Down
2 changes: 1 addition & 1 deletion docs/config_example/deepseek.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This project is not affiliated with, endorsed by, or sponsored by DeepSeek.
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"deepseek": {
Expand Down
47 changes: 41 additions & 6 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ OpenCode v2 uses `plugins` and `providers`. The plugin can be declared directly
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"my-provider": {
Expand Down 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"`, `"omniroute"`, and `"openrouter"` |
| `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"`, `"openrouter"`, 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 nine model info formats:
The plugin currently supports ten model info formats:

| Format | Source | Requires `modelInfoEndpoint` | Notes |
|--------|--------|------------------------------|-------|
Expand All @@ -368,6 +368,7 @@ The plugin currently supports nine model info formats:
| `"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 |
| `"openrouter"` | Fields in OpenRouter's `/v1/models` response | No | Reads OpenRouter inline limits, modalities, pricing, capabilities, and reasoning metadata when present |
| `"ollama"` | Ollama's `/api/show` per-model endpoint | No | Uses `/api/show` by default; makes one POST per discovered model id; set `modelInfoEndpoint` for another path or a proxy URL |

> **Configuration syntax**: All examples below use the primary **OpenCode v2** configuration shape (`providers.<id>.settings.modelsDiscovery`). The discovery options (`enabled`, `modelInfoFormat`, `models`, `smartModelName`, etc.) are identical between versions; for legacy OpenCode v1, place the same options under `provider.<id>.options.modelsDiscovery` (with `plugin: ["opencode-models-discovery"]` and `npm: "@ai-sdk/openai-compatible"`).

Expand Down Expand Up @@ -615,6 +616,40 @@ 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 Info

Use `modelInfoFormat: "ollama"` for Ollama providers. Ollama's OpenAI-compatible `/v1/models` response only contains ids, so the plugin discovers models normally and then calls Ollama's native `POST /api/show` once per discovered model to read context length and capabilities.

```jsonc
{
"providers": {
"ollama": {
"package": "@opencode/ai/providers/openai-compatible",
"settings": {
"baseURL": "http://127.0.0.1:11434/v1",
"modelsDiscovery": {
"enabled": true,
"modelInfoFormat": "ollama"
}
}
}
}
}
```

The endpoint defaults to `/api/show` on the provider base URL origin, so `http://127.0.0.1:11434/v1` becomes `http://127.0.0.1:11434/api/show`. Set `modelInfoEndpoint` only for a non-standard path or a proxy URL. Requests use the configured `timeoutMs` and are limited to four concurrent calls. A failed, timed-out, or malformed per-model response only disables enrichment for that model; discovery continues and other models are still enriched.

Context length is resolved in this order:

1. `num_ctx` from the model's `parameters` string, when positive;
2. `model_info.<general.architecture>.context_length`, then `model_info.<details.family>.context_length`;
3. any other positive `*.context_length` entry in `model_info`;
4. a top-level `context_length`.

`num_ctx` is preferred because it is the only intentional effective-context signal the model exposes. The `model_info` values are architecture-derived maxima and are used when `num_ctx` is absent or invalid. The plugin does not clamp `num_ctx` to the architecture maximum, and it cannot see server-wide runtime overrides such as `OLLAMA_CONTEXT_LENGTH`. When no value is available, the model keeps the host default context limit.

Capabilities are mapped as follows: `vision` or `images` adds image input, `audio` adds audio input, `thinking` sets `reasoning`, and `tools`, `function_calling`, or `tool_calling` sets `tool_call`. When `filterNonChat` is enabled, a model whose capabilities contain `embedding` but not `completion` is skipped.

### models.dev Metadata

Use `modelInfoFormat: "models.dev"` to enrich discovered models from the public [models.dev](https://models.dev) metadata index.
Expand Down Expand Up @@ -692,7 +727,7 @@ For providers with custom metadata paths or non-standard behavior:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"lmstudio": {
Expand Down Expand Up @@ -741,7 +776,7 @@ In this example:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"ollama": {
Expand Down
4 changes: 2 additions & 2 deletions docs/connect-and-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ OpenCode v2 features a first-class Integration subsystem (`ctx.integration`) and
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@latest"
"opencode-models-discovery@2.1.0"
],
"providers": {
"deepseek-custom": {
Expand Down Expand Up @@ -83,7 +83,7 @@ OpenCode v1 does not have the V2 plugin integration API. Instead, the V1 adapter
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-models-discovery@latest"
"opencode-models-discovery@2.1.0"
],
"provider": {
"test_provider": {
Expand Down
4 changes: 2 additions & 2 deletions docs/migrate-v1-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ OpenCode v2 is the stable, primary integration. The package contains separate ad
| OpenCode v1 | OpenCode v2 |
|---|---|
| `plugin` | `plugins` |
| `plugin: ["opencode-models-discovery"]` | `plugins: ["opencode-models-discovery@2.0.0"]` |
| `plugin: ["opencode-models-discovery"]` | `plugins: ["opencode-models-discovery@2.1.0"]` |
| `provider` | `providers` |
| `provider.<id>.npm` | `providers.<id>.package` |
| `provider.<id>.options` | `providers.<id>.settings` |
Expand Down Expand Up @@ -64,7 +64,7 @@ The V1 package commonly uses:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-models-discovery@2.0.0"
"opencode-models-discovery@2.1.0"
],
"providers": {
"gateway": {
Expand Down
25 changes: 25 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ The examples below use the OpenCode v1 keys `plugin`, `provider`, `npm`, and `op
}
```

### Ollama Metadata Enrichment

Ollama's OpenAI-compatible model list only exposes ids. Enable `modelInfoFormat: "ollama"` to read context lengths and capabilities from the native `POST /api/show` endpoint:

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

The plugin appends `/api/show` to the provider origin, so the example above calls `http://127.0.0.1:11434/api/show`. It requests each discovered model with bounded concurrency and degrades to no enrichment for models whose request fails. See [Ollama model info](configuration.md#ollama-model-info) for context-length precedence and capability mapping.

### Non-`/v1` Discovery Endpoint

```json
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode-models-discovery",
"version": "2.0.1",
"version": "2.1.0",
"description": "OpenCode plugin for auto-discovery of OpenAI-compatible models with dynamic provider configuration",
"type": "module",
"main": "./dist/index.js",
Expand Down
24 changes: 14 additions & 10 deletions src/core/enricher-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ModelInfoFormat } from '../types/plugin-config'
import { createModelEnricher, isSupportedModelInfoFormat, type ModelEnricher } from '../utils/model-info/index'
import { createDeferredModelEnricher, createModelEnricher, isSupportedModelInfoFormat, type ModelEnricher } from '../utils/model-info/index'
import { fetchModelsDevData, DEFAULT_MODELS_DEV_URL } from '../utils/models-dev-fetcher'
import { buildModelInfoURL } from './model-info-url'

export { buildModelInfoURL } from './model-info-url'

export const DEFAULT_LITELLM_ENDPOINT = '/v1/model/info'
export const DEFAULT_LMSTUDIO_ENDPOINT = '/api/v1/models'
Expand All @@ -26,15 +29,6 @@ export interface ModelInfoResolverContext {
readonly logger?: ModelInfoResolverLogger
}

export function buildModelInfoURL(baseURL: string, endpoint: string): string {
if (/^https?:\/\//i.test(endpoint)) {
return endpoint
}
return endpoint.startsWith('/')
? new URL(endpoint, new URL(baseURL).origin).toString()
: new URL(endpoint, baseURL.endsWith('/') ? baseURL : `${baseURL}/`).toString()
}

/**
* Shared model-info enricher resolution across V1 and V2 adapters.
* Dispatches to static indexes, inline/local formats, or remote provider endpoints.
Expand Down Expand Up @@ -79,6 +73,16 @@ export async function resolveModelInfoEnricher(
return createModelEnricher(format, null)
}

const deferred = createDeferredModelEnricher(format, {
baseURL: context.baseURL,
apiKey: context.apiKey,
endpoint: options.endpoint,
timeoutMs: options.timeoutMs ?? DEFAULT_ENRICHER_TIMEOUT_MS,
fetcher: context.fetcher,
logger: context.logger,
})
if (deferred) return deferred

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
49 changes: 49 additions & 0 deletions src/core/model-enrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ export interface ModelEnrichmentContext {
readonly filterNonChat: boolean
}

export interface ModelEnricherLogger {
warn(message: string, context?: Record<string, unknown>): void
info?(message: string, context?: Record<string, unknown>): void
}

/**
* Request bindings passed to deferred enricher factories at resolution time.
* Format modules use these to construct an enricher that performs per-model
* metadata requests later in prepare().
*/
export interface ModelEnricherDeferredContext {
readonly baseURL: string
readonly apiKey?: string
readonly endpoint?: string
readonly timeoutMs?: number
readonly fetcher?: typeof fetch
readonly logger?: ModelEnricherLogger
}

export interface ModelEnrichmentResult {
readonly skip?: boolean
readonly metadataName?: string
Expand All @@ -24,9 +43,39 @@ export interface ModelEnrichmentResult {
}

export interface ModelEnricher {
/**
* Optional deferred hydration step. Called at most once per discovery run,
* after the provider model list is known and before any enrich() call.
*
* Implementations may perform network requests to fetch per-model metadata.
* Individual failures must degrade to "no enrichment" for that model and
* must not reject; enrichment is always best-effort.
*/
prepare?(modelIds: readonly string[]): Promise<void>

enrich(model: DiscoveredRawModel, context: ModelEnrichmentContext): ModelEnrichmentResult
}

/**
* Runs the optional deferred hydration step of an enricher.
*
* Adapters call this after the provider model list is available and before
* building discovery drafts, so enrichers that need per-model detail requests
* can keep enrich() synchronous. Preparation is best-effort: a failure must
* never abort discovery.
*/
export async function prepareModelEnricher(
enricher: ModelEnricher | undefined,
modelIds: readonly string[],
): Promise<void> {
if (!enricher?.prepare) return
try {
await enricher.prepare(modelIds)
} catch {
// Deferred enrichment is best-effort; discovery must continue.
}
}

export interface EnrichedModelDraft {
readonly draft: DiscoveredModelDraft
readonly metadataName?: string
Expand Down
Loading
Loading