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
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 `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.

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
33 changes: 31 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ Each provider can configure discovery behavior through `provider.<name>.options.
| `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.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, `"omniroute"`, and `"openrouter"` |
| `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 |
| `"openrouter"` | Fields in OpenRouter's `/v1/models` response | No | Reads OpenRouter inline limits, modalities, pricing, capabilities, and reasoning metadata when present |

### AIProxy Metadata

Expand Down Expand Up @@ -478,6 +479,34 @@ When OmniRoute advertises service-tier metadata through `capabilities.service_ti

This format is intentionally explicit because these fields are OmniRoute extensions to the generic OpenAI-compatible model-list response. For the most complete OmniRoute integration, including dynamic provider support, use OmniRoute's official `@omniroute/opencode-plugin`.

### OpenRouter Model Info

Use `modelInfoFormat: "openrouter"` for an [OpenRouter](https://openrouter.ai/) provider. It reads OpenRouter's inline metadata from the same `/v1/models` response and does not make another metadata request.

```json
{
"plugin": ["opencode-models-discovery"],
"provider": {
"openrouter": {
"npm": "@ai-sdk/openai-compatible",
"name": "OpenRouter",
"options": {
"baseURL": "https://openrouter.ai/api/v1",
"apiKey": "YOUR_OPENROUTER_API_KEY",
"modelsDiscovery": {
"enabled": true,
"modelInfoFormat": "openrouter",
"smartModelName": true
}
},
"models": {}
}
}
}
```

For each discovered model, the plugin maps `context_length` and `top_provider.max_completion_tokens` to context and output limits, `architecture` modalities to OpenCode modalities, and OpenRouter's prompt/completion and cache pricing to per-million-token costs. It maps `supported_parameters` entries for tools and temperature, and converts `reasoning.supported_efforts` into reasoning variants. A non-empty `name` is used as the display name when `smartModelName` is enabled. Missing or malformed metadata is left unset without preventing discovery.

### Bifrost Model Info

Use `modelInfoFormat: "bifrost"` for a Bifrost AI Gateway provider. It reads Bifrost's documented inline metadata from the same `/v1/models` response and does not make another metadata request.
Expand Down
2 changes: 1 addition & 1 deletion docs/v1-v2-shared-core-refactor-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ Owner resolution has been unified into `resolveModelOwner` in `src/core/model-na

### 5. V1 and V2 still duplicate model-info enricher resolution (Resolved in Phase 5)

Enricher resolution has been extracted into `resolveModelInfoEnricher` in `src/core/enricher-resolver.ts`. Both V1 and V2 adapters share the exact same resolution rules for static index formats (`models.dev`, `aiproxy`), inline/zero-request formats (`bifrost`, `vllm`, `llama-swap`, `omniroute`), and remote provider endpoints (`litellm`, `lmstudio`). Credentials and transport bindings remain host-owned while format handling is 100% unified.
Enricher resolution has been extracted into `resolveModelInfoEnricher` in `src/core/enricher-resolver.ts`. Both V1 and V2 adapters share the exact same resolution rules for static index formats (`models.dev`, `aiproxy`), inline/zero-request formats (`bifrost`, `vllm`, `llama-swap`, `omniroute`, `openrouter`), and remote provider endpoints (`litellm`, `lmstudio`). Credentials and transport bindings remain host-owned while format handling is 100% unified.

### 6. Existing parity tests do not cover both complete adapter projections (Resolved in Phase 5)

Expand Down
3 changes: 2 additions & 1 deletion src/core/enricher-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export async function resolveModelInfoEnricher(
format === ModelInfoFormat.Bifrost ||
format === ModelInfoFormat.VLLM ||
format === ModelInfoFormat.LlamaSwap ||
format === ModelInfoFormat.OmniRoute
format === ModelInfoFormat.OmniRoute ||
format === ModelInfoFormat.OpenRouter
) {
return createModelEnricher(format, null)
}
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="openrouter": read OpenRouter's inline /v1/models limits, modalities, pricing, capabilities, and reasoning metadata without another request
- 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="openrouter" only for OpenRouter's /v1/models response; it is an explicit inline-metadata format and does not make another request
- 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/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',
OpenRouter = 'openrouter',
}

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
Expand Up @@ -6,6 +6,7 @@ import { createModelsDevEnricher } from './models-dev'
import { createOmniRouteEnricher } from './omniroute'
import { createVLLMEnricher } from './vllm'
import { createAIProxyEnricher } from './aiproxy'
import { createOpenRouterEnricher } from './openrouter'
import { ModelInfoFormat } from '../../types/plugin-config'
import type { ModelEnricher, ModelEnrichmentContext } from '../../core/model-enrichment'

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.OpenRouter]: (data) => createOpenRouterEnricher(data),
}

/** Creates the neutral contract used by the shared discovery pipeline. */
Expand Down
117 changes: 117 additions & 0 deletions src/utils/model-info/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { ModelEnricher } from '../../core/model-enrichment'
import { createModelLimits } from '../../core/model-types'

const SUPPORTED_MODALITIES = new Set(['text', 'audio', 'image', 'video', 'pdf'])
const SUPPORTED_REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])

function record(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}

function usableNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
}

function nonNegativeCostPerMillion(value: unknown): number | undefined {
const parsed = typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== '' ? Number(value) : undefined

if (typeof parsed !== 'number' || !Number.isFinite(parsed) || parsed < 0) return undefined
const perMillion = parsed * 1_000_000
return Number.isFinite(perMillion) ? Number(perMillion.toPrecision(15)) : undefined
}

function modalities(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined

const result = [...new Set(value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.map((item) => item === 'speech' ? 'audio' : item)
.filter((item) => SUPPORTED_MODALITIES.has(item)))]

return result.length > 0 ? result : undefined
}

function reasoningVariants(value: unknown): Record<string, { reasoningEffort: string }> | undefined {
const reasoning = record(value)
if (!Array.isArray(reasoning?.supported_efforts)) return undefined

const variants: Record<string, { reasoningEffort: string }> = {}
for (const effort of reasoning.supported_efforts) {
if (typeof effort !== 'string') continue
const normalized = effort.trim().toLowerCase()
if (SUPPORTED_REASONING_EFFORTS.has(normalized)) {
variants[normalized] = { reasoningEffort: normalized }
}
}

return Object.keys(variants).length > 0 ? variants : undefined
}

export function createOpenRouterEnricher(_data: unknown): ModelEnricher {
return {
enrich(model) {
const result: Record<string, unknown> = {}
const name = model.name
if (typeof name === 'string' && name.trim().length > 0) {
result.metadataName = name.trim()
}

const context = usableNumber(model.context_length) ? model.context_length : undefined
const topProvider = record(model.top_provider)
const output = usableNumber(topProvider?.max_completion_tokens)
? topProvider.max_completion_tokens
: undefined
if (context) {
result.limit = createModelLimits(context, output)
}

const architecture = record(model.architecture)
const inputModalities = modalities(architecture?.input_modalities)
const outputModalities = modalities(architecture?.output_modalities)
if (inputModalities || outputModalities) {
result.modalities = {
...(inputModalities ? { input: inputModalities } : {}),
...(outputModalities ? { output: outputModalities } : {}),
}
}

const pricing = record(model.pricing)
const input = nonNegativeCostPerMillion(pricing?.prompt)
const outputCost = nonNegativeCostPerMillion(pricing?.completion)
const cacheRead = nonNegativeCostPerMillion(pricing?.input_cache_read)
const cacheWrite = nonNegativeCostPerMillion(pricing?.input_cache_write)
if (input !== undefined && outputCost !== undefined) {
result.cost = {
input,
output: outputCost,
...(cacheRead !== undefined ? { cache_read: cacheRead } : {}),
...(cacheWrite !== undefined ? { cache_write: cacheWrite } : {}),
}
}

const supportedParameters = Array.isArray(model.supported_parameters)
? model.supported_parameters.filter((item): item is string => typeof item === 'string')
: []
if (supportedParameters.includes('tools') || supportedParameters.includes('tool_choice')) {
result.toolCall = true
}
if (supportedParameters.length > 0) {
result.temperature = supportedParameters.includes('temperature')
}

const reasoning = record(model.reasoning)
const variants = reasoningVariants(reasoning)
if (variants) result.variants = variants
if (reasoning?.mandatory === true || reasoning?.default_enabled === true || variants) {
result.reasoning = true
}

return result
},
}
}
11 changes: 11 additions & 0 deletions test/core/enricher-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ describe('shared enricher resolver', () => {
expect(fetcher).not.toHaveBeenCalled()
})

it('resolves OpenRouter inline metadata without a network request', async () => {
const fetcher = vi.fn()
const enricher = await resolveModelInfoEnricher(
{ baseURL: 'https://openrouter.ai/api/v1', fetcher },
{ format: ModelInfoFormat.OpenRouter },
)

expect(enricher).toBeDefined()
expect(fetcher).not.toHaveBeenCalled()
})

it('resolves remote LiteLLM format with authentication and timeout', async () => {
const fetcher = vi.fn().mockResolvedValue({
ok: true,
Expand Down
33 changes: 33 additions & 0 deletions test/core/model-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createBifrostEnricher } from '../../src/utils/model-info/bifrost'
import { createVLLMEnricher } from '../../src/utils/model-info/vllm'
import { createLlamaSwapEnricher } from '../../src/utils/model-info/llamaswap'
import { createOmniRouteEnricher } from '../../src/utils/model-info/omniroute'
import { createOpenRouterEnricher } from '../../src/utils/model-info/openrouter'
import { createLMStudioEnricher } from '../../src/utils/model-info/lmstudio'
import { createAIProxyEnricher } from '../../src/utils/model-info/aiproxy'

Expand Down Expand Up @@ -189,6 +190,38 @@ describe('V1/V2 shared-core parity', () => {
expect(mapped.reasoning).toBe(true)
})

it('keeps native OpenRouter enrichment equivalent for V1 drafts and V2 models', () => {
const drafts = discoverModelDrafts([{
id: 'openai/gpt-6.1-sol',
name: 'OpenAI: GPT-6.1 Sol',
context_length: 1_048_576,
top_provider: { max_completion_tokens: 65_536 },
architecture: { input_modalities: ['text', 'image'], output_modalities: ['text'] },
pricing: { prompt: '0.000002', completion: '0.00001' },
supported_parameters: ['tools'],
reasoning: { supported_efforts: ['low', 'medium', 'high'] },
}], {
filter: { includeBy: [], excludeBy: [], includeRegex: [], excludeRegex: [] },
smartModelName: true,
enricher: createOpenRouterEnricher(null),
enrichmentContext: { filterNonChat: true },
})
const draft = drafts[0]!
const mapped = mapToDiscoveredV2Model(draft, options)

expect(draft.name).toBe('OpenAI: GPT-6.1 Sol')
expect(draft.limit).toEqual({ context: 1_048_576, output: 65_536 })
expect(draft.modalities).toEqual({ input: ['text', 'image'], output: ['text'] })
expect(draft.cost).toEqual({ input: 2, output: 10 })
expect(draft.toolCall).toBe(true)
expect(draft.reasoning).toBe(true)
expect(mapped.name).toBe(draft.name)
expect(mapped.limit).toEqual(draft.limit)
expect(mapped.capabilities.input).toEqual(['text', 'image'])
expect(mapped.capabilities.tools).toBe(true)
expect(mapped.cost).toEqual([{ input: 2, output: 10, cache: { read: 0, write: 0 } }])
})

it('keeps native LM Studio enrichment equivalent for V1 drafts and V2 models', () => {
const enricher = createLMStudioEnricher({ models: [{
key: 'lmstudio/gemma',
Expand Down
Loading
Loading