diff --git a/README.md b/README.md index 975ac08..93fc79d 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/docs/configuration.md b/docs/configuration.md index 6d6d646..278d8de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,7 +117,7 @@ Each provider can configure discovery behavior through `provider..options. | `provider..options.modelsDiscovery.endpoint` | `string` | Provider-specific models endpoint as an origin-relative path beginning with `/`. Defaults to `/v1/models` | | `provider..options.modelsDiscovery.timeoutMs` | positive finite `number` | Per-request timeout for the provider's models and provider-specific metadata endpoints. Defaults to `3000` | | `provider..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..options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, and `"omniroute"` | +| `provider..options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, `"omniroute"`, and `"openrouter"` | | `provider..options.modelsDiscovery.filterNonChat` | `boolean` | When model info is available, skip models whose `model_info.mode` is not `chat`. Defaults to `true` | | `provider..options.modelsDiscovery.models.includeRegex` | `string[]` | Shortcut regex allow-list for discovered model ids only | | `provider..options.modelsDiscovery.models.excludeRegex` | `string[]` | Shortcut regex deny-list for discovered model ids only | @@ -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 | |--------|--------|------------------------------|-------| @@ -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 @@ -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. diff --git a/docs/v1-v2-shared-core-refactor-prd.md b/docs/v1-v2-shared-core-refactor-prd.md index ac8379f..6b76dcd 100644 --- a/docs/v1-v2-shared-core-refactor-prd.md +++ b/docs/v1-v2-shared-core-refactor-prd.md @@ -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) diff --git a/src/core/enricher-resolver.ts b/src/core/enricher-resolver.ts index d1d9e5b..c395fb2 100644 --- a/src/core/enricher-resolver.ts +++ b/src/core/enricher-resolver.ts @@ -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) } diff --git a/src/plugin/commands.ts b/src/plugin/commands.ts index 2b16f64..d3b9e20 100644 --- a/src/plugin/commands.ts +++ b/src/plugin/commands.ts @@ -126,6 +126,7 @@ Supported plugin options under provider..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 @@ -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 diff --git a/src/types/plugin-config.ts b/src/types/plugin-config.ts index fd4105f..d529e40 100644 --- a/src/types/plugin-config.ts +++ b/src/types/plugin-config.ts @@ -38,6 +38,7 @@ export enum ModelInfoFormat { LMStudio = 'lmstudio', LlamaSwap = 'llama-swap', OmniRoute = 'omniroute', + OpenRouter = 'openrouter', } export const DEFAULT_CACHE_TTL_SECONDS = 86400 diff --git a/src/utils/model-info/index.ts b/src/utils/model-info/index.ts index 79350df..0273fd0 100644 --- a/src/utils/model-info/index.ts +++ b/src/utils/model-info/index.ts @@ -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' @@ -20,6 +21,7 @@ const MODEL_ENRICHERS: Partial> = [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. */ diff --git a/src/utils/model-info/openrouter.ts b/src/utils/model-info/openrouter.ts new file mode 100644 index 0000000..f01108f --- /dev/null +++ b/src/utils/model-info/openrouter.ts @@ -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 | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : 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 | undefined { + const reasoning = record(value) + if (!Array.isArray(reasoning?.supported_efforts)) return undefined + + const variants: Record = {} + 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 = {} + 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 + }, + } +} diff --git a/test/core/enricher-resolver.test.ts b/test/core/enricher-resolver.test.ts index 2b0431b..6eaa495 100644 --- a/test/core/enricher-resolver.test.ts +++ b/test/core/enricher-resolver.test.ts @@ -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, diff --git a/test/core/model-parity.test.ts b/test/core/model-parity.test.ts index 3691a77..efacda6 100644 --- a/test/core/model-parity.test.ts +++ b/test/core/model-parity.test.ts @@ -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' @@ -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', diff --git a/test/openrouter-model-info.test.ts b/test/openrouter-model-info.test.ts new file mode 100644 index 0000000..295a62f --- /dev/null +++ b/test/openrouter-model-info.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { createOpenRouterEnricher } from '../src/utils/model-info/openrouter' + +describe('OpenRouter model info enricher', () => { + it('extracts inline metadata from an OpenRouter model entry', () => { + const result = createOpenRouterEnricher(null).enrich({ + id: 'openai/gpt-6.1-sol', + name: 'OpenAI: GPT-6.1 Sol', + context_length: 1_048_576, + architecture: { + input_modalities: ['text', 'image'], + output_modalities: ['text'], + }, + pricing: { + prompt: '0.000002', + completion: '0.00001', + input_cache_read: '0.0000001', + input_cache_write: '0.0000001875', + }, + top_provider: { + max_completion_tokens: 65_536, + }, + supported_parameters: ['temperature', 'tools', 'reasoning_effort'], + reasoning: { + mandatory: true, + supported_efforts: ['low', 'medium', 'high'], + }, + }, { filterNonChat: true }) + + expect(result).toEqual({ + metadataName: 'OpenAI: GPT-6.1 Sol', + limit: { context: 1_048_576, output: 65_536 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + cost: { + input: 2, + output: 10, + cache_read: 0.1, + cache_write: 0.1875, + }, + reasoning: true, + variants: { + low: { reasoningEffort: 'low' }, + medium: { reasoningEffort: 'medium' }, + high: { reasoningEffort: 'high' }, + }, + toolCall: true, + temperature: true, + }) + }) + + it('leaves malformed optional metadata unset', () => { + const result = createOpenRouterEnricher(null).enrich({ + id: 'provider/model', + name: '', + context_length: 0, + architecture: { input_modalities: ['unsupported'] }, + pricing: { prompt: 'invalid', completion: -1 }, + top_provider: { max_completion_tokens: '8192' }, + reasoning: { supported_efforts: ['ultra'] }, + }, { filterNonChat: true }) + + expect(result).toEqual({}) + }) +}) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 691a9d6..a58c804 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -281,6 +281,7 @@ describe('ModelDiscovery Plugin', () => { expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="lmstudio"') expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="llama-swap"') expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="omniroute"') + expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="openrouter"') expect(config.command['models-discovery:config'].template).toContain('max_model_len') expect(config.command['models-discovery:config'].template).toContain('restart opencode')