diff --git a/README.md b/README.md index c5e233e..ef31655 100644 --- a/README.md +++ b/README.md @@ -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": { @@ -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 ` 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. @@ -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": { @@ -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 diff --git a/docs/config_example/a2agent.md b/docs/config_example/a2agent.md index d2c7f35..8f37b36 100644 --- a/docs/config_example/a2agent.md +++ b/docs/config_example/a2agent.md @@ -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": { diff --git a/docs/config_example/ai-router.md b/docs/config_example/ai-router.md index 133986f..0daf6c8 100644 --- a/docs/config_example/ai-router.md +++ b/docs/config_example/ai-router.md @@ -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": { diff --git a/docs/config_example/deepseek.md b/docs/config_example/deepseek.md index e5bd85e..7a8a54c 100644 --- a/docs/config_example/deepseek.md +++ b/docs/config_example/deepseek.md @@ -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": { diff --git a/docs/configuration.md b/docs/configuration.md index 07a0d4b..7b6db6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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": { @@ -116,8 +116,8 @@ Each provider can configure discovery behavior through `provider..options. | `provider..options.modelsDiscovery.enabled` | `boolean` | Force enable or disable discovery for a single provider | | `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"`, `"omniroute"`, and `"openrouter"` | +| `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"`, `/api/v1/models` for `"lmstudio"`, and `/api/show` for `"ollama"`; 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"`, `"omniroute"`, `"openrouter"`, and `"ollama"` | | `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 nine model info formats: +The plugin currently supports ten model info formats: | Format | Source | Requires `modelInfoEndpoint` | Notes | |--------|--------|------------------------------|-------| @@ -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..settings.modelsDiscovery`). The discovery options (`enabled`, `modelInfoFormat`, `models`, `smartModelName`, etc.) are identical between versions; for legacy OpenCode v1, place the same options under `provider..options.modelsDiscovery` (with `plugin: ["opencode-models-discovery"]` and `npm: "@ai-sdk/openai-compatible"`). @@ -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..context_length`, then `model_info..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. @@ -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": { @@ -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": { diff --git a/docs/connect-and-auth.md b/docs/connect-and-auth.md index bce4379..564081b 100644 --- a/docs/connect-and-auth.md +++ b/docs/connect-and-auth.md @@ -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": { @@ -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": { diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 0f760b3..bfda602 100644 --- a/docs/migrate-v1-to-v2.md +++ b/docs/migrate-v1-to-v2.md @@ -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..npm` | `providers..package` | | `provider..options` | `providers..settings` | @@ -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": { diff --git a/docs/providers.md b/docs/providers.md index 4eb4d4c..2c11b13 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 diff --git a/package-lock.json b/package-lock.json index fecdb64..b9c2957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-models-discovery", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-models-discovery", - "version": "2.0.1", + "version": "2.1.0", "license": "MIT", "dependencies": { "@opencode/plugin": "2.0.32", diff --git a/package.json b/package.json index 6edaaa5..c9816cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/enricher-resolver.ts b/src/core/enricher-resolver.ts index c395fb2..35ef83b 100644 --- a/src/core/enricher-resolver.ts +++ b/src/core/enricher-resolver.ts @@ -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' @@ -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. @@ -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 diff --git a/src/core/model-enrichment.ts b/src/core/model-enrichment.ts index 1ae6a34..5a5ca54 100644 --- a/src/core/model-enrichment.ts +++ b/src/core/model-enrichment.ts @@ -4,6 +4,25 @@ export interface ModelEnrichmentContext { readonly filterNonChat: boolean } +export interface ModelEnricherLogger { + warn(message: string, context?: Record): void + info?(message: string, context?: Record): 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 @@ -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 + 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 { + 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 diff --git a/src/core/model-info-url.ts b/src/core/model-info-url.ts new file mode 100644 index 0000000..a748b75 --- /dev/null +++ b/src/core/model-info-url.ts @@ -0,0 +1,16 @@ +/** + * Resolves a model-info endpoint against a provider base URL. + * + * Absolute URLs are returned unchanged. Endpoints starting with "/" are + * resolved against the provider origin, which is the common case for native + * endpoints such as Ollama's /api/show. Other relative paths are resolved + * against the provider base URL. + */ +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() +} diff --git a/src/plugin/commands.ts b/src/plugin/commands.ts index d3b9e20..6af79a7 100644 --- a/src/plugin/commands.ts +++ b/src/plugin/commands.ts @@ -112,7 +112,7 @@ Explain the mechanism to the user: Supported plugin options under provider..options.modelsDiscovery: - enabled: force enable or disable discovery for this provider - endpoint: provider-specific models endpoint as an origin-relative path beginning with /; it always uses the provider base URL origin and defaults to /v1/models -- modelInfoEndpoint: override the metadata endpoint; accepts either an origin-relative path or a complete URL for "litellm" and "lmstudio", while "models.dev" requires a complete models.json URL +- modelInfoEndpoint: override the metadata endpoint; accepts either an origin-relative path or a complete URL for "litellm", "lmstudio", and "ollama", while "models.dev" requires a complete models.json URL - modelInfoFormat="aiproxy": overlay inline AIProxy limits, pricing, and explicitly advertised effort tiers on models.dev metadata; modelInfoEndpoint optionally overrides the models.dev URL - models.includeRegex: shortcut for model id regex allow-list; prefer models.includeBy with field="id" and match for new config - models.excludeRegex: shortcut for model id regex deny-list; prefer models.excludeBy with field="id" and match for new config @@ -127,6 +127,7 @@ Supported plugin options under provider..options.modelsDiscovery: - 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 +- modelInfoFormat="ollama": discover through the normal /v1/models endpoint, then read per-model context length and capabilities from Ollama's native POST /api/show endpoint - 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 @@ -155,6 +156,7 @@ Recommended defaults: - 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 +- use modelInfoFormat="ollama" for Ollama providers that expose the native /api/show endpoint; it makes one POST per discovered model, keeps going when individual models fail, and does not require modelInfoEndpoint unless Ollama is behind a non-standard path or proxy - 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/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index d8404c8..1021b63 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -8,7 +8,7 @@ import { type ModelEnricher } from '../utils/model-info' import { DEFAULT_CACHE_TTL_SECONDS, getDefaultDiscoveryConfigFromEnv, getProviderModelFieldFilters, getProviderModelRegexFilter, shouldDiscoverProviderWithOverride } from '../types/plugin-config' import { isInventoryFresh, mergeModelOverride, ProviderModelStore, type ProviderModelState } from './provider-model-store' import type { PluginLogger } from './logger' -import type { ModelEnrichmentResult } from '../core/model-enrichment' +import { prepareModelEnricher, type ModelEnrichmentResult } from '../core/model-enrichment' import type { PluginInput } from '@opencode-ai/plugin' import type { PluginConfig } from '../types/plugin-config' import { discoverModelDrafts } from '../core/discovery-pipeline' @@ -344,6 +344,12 @@ export async function enhanceConfig( if (reprocessPersistedModels) { modelInfoEnricher = createCachedModelEnricher(persistedState?.models ?? {}) } + if (!usingPersistedModels) { + await prepareModelEnricher( + modelInfoEnricher, + models.filter(isDiscoveredRawModel).map((model) => model.id), + ) + } const existingModels = getExplicitModels(config, providerName, p.models || {}) diff --git a/src/types/plugin-config.ts b/src/types/plugin-config.ts index d529e40..9db70e8 100644 --- a/src/types/plugin-config.ts +++ b/src/types/plugin-config.ts @@ -39,6 +39,7 @@ export enum ModelInfoFormat { LlamaSwap = 'llama-swap', OmniRoute = 'omniroute', OpenRouter = 'openrouter', + Ollama = 'ollama', } export const DEFAULT_CACHE_TTL_SECONDS = 86400 diff --git a/src/utils/model-info/index.ts b/src/utils/model-info/index.ts index 0273fd0..1ef20be 100644 --- a/src/utils/model-info/index.ts +++ b/src/utils/model-info/index.ts @@ -7,10 +7,12 @@ import { createOmniRouteEnricher } from './omniroute' import { createVLLMEnricher } from './vllm' import { createAIProxyEnricher } from './aiproxy' import { createOpenRouterEnricher } from './openrouter' +import { createOllamaEnricher } from './ollama' import { ModelInfoFormat } from '../../types/plugin-config' -import type { ModelEnricher, ModelEnrichmentContext } from '../../core/model-enrichment' +import type { ModelEnricher, ModelEnricherDeferredContext, ModelEnrichmentContext } from '../../core/model-enrichment' type ModelEnricherFactory = (data: unknown, options?: ModelEnrichmentContext) => ModelEnricher +type DeferredModelEnricherFactory = (context: ModelEnricherDeferredContext) => ModelEnricher const MODEL_ENRICHERS: Partial> = { [ModelInfoFormat.ModelsDev]: (data) => createModelsDevEnricher(data), @@ -24,6 +26,15 @@ const MODEL_ENRICHERS: Partial> = [ModelInfoFormat.OpenRouter]: (data) => createOpenRouterEnricher(data), } +/** + * Formats whose metadata requires a second per-model request after the probe + * list is known. Their factories only bind request context; network work + * happens later in ModelEnricher.prepare(). + */ +const DEFERRED_MODEL_ENRICHERS: Partial> = { + [ModelInfoFormat.Ollama]: (context) => createOllamaEnricher(context), +} + /** Creates the neutral contract used by the shared discovery pipeline. */ export function createModelEnricher( format: ModelInfoFormat, @@ -33,8 +44,16 @@ export function createModelEnricher( return MODEL_ENRICHERS[format]?.(data, options) } +/** Creates a request-bound enricher whose hydration runs in prepare(). */ +export function createDeferredModelEnricher( + format: ModelInfoFormat, + context: ModelEnricherDeferredContext, +): ModelEnricher | undefined { + return DEFERRED_MODEL_ENRICHERS[format]?.(context) +} + export function isSupportedModelInfoFormat(format: ModelInfoFormat): boolean { - return MODEL_ENRICHERS[format] !== undefined + return MODEL_ENRICHERS[format] !== undefined || DEFERRED_MODEL_ENRICHERS[format] !== undefined } export { createModelsDevEnricher } from './models-dev' diff --git a/src/utils/model-info/ollama.ts b/src/utils/model-info/ollama.ts new file mode 100644 index 0000000..73f4894 --- /dev/null +++ b/src/utils/model-info/ollama.ts @@ -0,0 +1,198 @@ +import type { ModelEnricher, ModelEnricherDeferredContext } from '../../core/model-enrichment' +import { createModelLimits } from '../../core/model-types' +import { buildModelInfoURL } from '../../core/model-info-url' + +export const DEFAULT_OLLAMA_ENDPOINT = '/api/show' +export const DEFAULT_OLLAMA_CONCURRENCY = 4 +const DEFAULT_OLLAMA_TIMEOUT_MS = 5000 + +interface ParsedOllamaMetadata { + readonly contextLength?: number + readonly capabilities: ReadonlySet +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +function parseCapabilities(value: unknown): ReadonlySet { + const capabilities = new Set() + if (!Array.isArray(value)) return capabilities + + for (const entry of value) { + if (typeof entry === 'string' && entry.trim().length > 0) { + capabilities.add(entry.trim().toLowerCase()) + } + } + return capabilities +} + +/** + * Reads num_ctx from Ollama's Modelfile-style parameters string. Accepts the + * forms returned by different Ollama versions: "num_ctx 4096", + * "num_ctx: 8192", and "\"num_ctx\": 16384". + */ +function parseNumCtx(parameters: unknown): number | undefined { + if (isRecord(parameters)) { + return positiveNumber(parameters.num_ctx) + } + if (typeof parameters !== 'string') return undefined + + const match = parameters.match(/(?:^|\n)\s*"?num_ctx"?\s*[:=]?\s*(\d+)\b/i) + return match ? positiveNumber(Number(match[1])) : undefined +} + +/** + * Resolves the effective context window. + * + * A num_ctx from the model's parameters is the only intentional runtime + * context signal Ollama exposes, so it wins over architecture-derived values. + * When it is absent, fall back to model_info..context_length, + * then any *.context_length entry, then a top-level context_length. + */ +function resolveContextLength(show: Record): number | undefined { + const numCtx = parseNumCtx(show.parameters) + if (numCtx) return numCtx + + const modelInfo = isRecord(show.model_info) ? show.model_info : undefined + if (modelInfo) { + const architecture = typeof modelInfo['general.architecture'] === 'string' + ? modelInfo['general.architecture'] + : undefined + const family = isRecord(show.details) && typeof show.details.family === 'string' + ? show.details.family + : undefined + + for (const name of [architecture, family]) { + if (!name) continue + const value = positiveNumber(modelInfo[`${name}.context_length`]) + if (value) return value + } + + let fallback: number | undefined + for (const [key, value] of Object.entries(modelInfo)) { + if (!key.endsWith('.context_length')) continue + const candidate = positiveNumber(value) + if (candidate && (fallback === undefined || candidate > fallback)) { + fallback = candidate + } + } + if (fallback) return fallback + } + + return positiveNumber(show.context_length) +} + +function parseShow(value: unknown): ParsedOllamaMetadata | undefined { + if (!isRecord(value)) return undefined + + const capabilities = parseCapabilities(value.capabilities) + const contextLength = resolveContextLength(value) + if (contextLength === undefined && capabilities.size === 0) return undefined + + return { + ...(contextLength !== undefined ? { contextLength } : {}), + capabilities, + } +} + +/** + * Creates a deferred enricher backed by Ollama's POST /api/show endpoint. + * + * The enricher does not make requests at resolution time. prepare() performs + * bounded-concurrency per-model requests after the provider model list is + * known; enrich() is a synchronous lookup. Per-model failures are ignored so + * discovery always continues. + */ +export function createOllamaEnricher(options: ModelEnricherDeferredContext): ModelEnricher { + const metadata = new Map() + const showUrl = buildModelInfoURL(options.baseURL, options.endpoint ?? DEFAULT_OLLAMA_ENDPOINT) + const fetcher = options.fetcher ?? globalThis.fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_OLLAMA_TIMEOUT_MS + const headers = new Headers({ accept: 'application/json', 'content-type': 'application/json' }) + if (options.apiKey) headers.set('authorization', `Bearer ${options.apiKey}`) + + async function fetchShow(modelId: string): Promise { + try { + const response = await fetcher(showUrl, { + method: 'POST', + headers, + body: JSON.stringify({ model: modelId }), + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) { + options.logger?.warn('Ollama model info discovery failed', { + endpoint: showUrl, + modelId, + status: response.status, + }) + return undefined + } + return parseShow(await response.json()) + } catch (error) { + options.logger?.warn('Ollama model info discovery failed', { + endpoint: showUrl, + modelId, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } + + return { + async prepare(modelIds) { + const ids = [...new Set(modelIds)].filter((id) => id.length > 0 && !metadata.has(id)) + let cursor = 0 + + const worker = async (): Promise => { + while (cursor < ids.length) { + const modelId = ids[cursor++] + const parsed = await fetchShow(modelId) + if (parsed) metadata.set(modelId, parsed) + } + } + + await Promise.all( + Array.from({ length: Math.min(DEFAULT_OLLAMA_CONCURRENCY, ids.length) }, () => worker()), + ) + }, + + enrich(model, context) { + const parsed = metadata.get(model.id) + if (!parsed) return {} + + const capabilities = parsed.capabilities + if (context.filterNonChat && capabilities.has('embedding') && !capabilities.has('completion')) { + return { skip: true } + } + + const result: { + limit?: ReturnType + modalities?: { input: string[]; output: string[] } + reasoning?: boolean + toolCall?: boolean + } = {} + + if (parsed.contextLength !== undefined) { + const limits = createModelLimits(parsed.contextLength) + if (limits) result.limit = limits + } + + const input = ['text'] + if (capabilities.has('vision') || capabilities.has('images')) input.push('image') + if (capabilities.has('audio')) input.push('audio') + if (input.length > 1) result.modalities = { input, output: ['text'] } + + if (capabilities.has('thinking')) result.reasoning = true + if (capabilities.has('tools') || capabilities.has('function_calling') || capabilities.has('tool_calling')) { + result.toolCall = true + } + + return result + }, + } +} diff --git a/src/v2/discovery.ts b/src/v2/discovery.ts index 6c5d7e6..c6870ab 100644 --- a/src/v2/discovery.ts +++ b/src/v2/discovery.ts @@ -3,7 +3,8 @@ import { type ConfiguredProvider, type DiscoveredV2Model, type Inventory } from import { type ProviderDiscoveryOptions } from "./provider-config.js" import { mapToDiscoveredV2Model } from "../core/model-mapper.js" import { discoverModelDrafts } from "../core/discovery-pipeline.js" -import type { ModelEnrichmentResult, ModelEnricher } from "../core/model-enrichment.js" +import { isDiscoveredRawModel } from "../core/model-types.js" +import { prepareModelEnricher, type ModelEnrichmentResult, type ModelEnricher } from "../core/model-enrichment.js" import { createDiscoveryCacheEntry, discoveryCacheKey, readDiscoveryCache, writeDiscoveryCache, type DiscoveryCacheBackend, type DiscoveryCacheEntry } from "../core/discovery-cache.js" import { resolveModelInfoEnricher } from "../core/enricher-resolver.js" @@ -142,6 +143,13 @@ export async function discoverInventory( enricher = resolvedEnricher } + if (!cached) { + await prepareModelEnricher( + enricher, + rawModels.filter(isDiscoveredRawModel).map((model) => model.id), + ) + } + const enrichments: Record = {} const recordingEnricher = enricher ? { enrich(model: Parameters[0], context: Parameters[1]) { diff --git a/test/core/enricher-resolver.test.ts b/test/core/enricher-resolver.test.ts index 6eaa495..9e81360 100644 --- a/test/core/enricher-resolver.test.ts +++ b/test/core/enricher-resolver.test.ts @@ -33,6 +33,34 @@ describe('shared enricher resolver', () => { expect(fetcher).not.toHaveBeenCalled() }) + it('resolves Ollama as a deferred enricher without fetching at resolution time', async () => { + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + parameters: 'num_ctx 8192', + capabilities: ['completion', 'tools'], + }), + }) + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher }, + { format: ModelInfoFormat.Ollama }, + ) + + expect(enricher).toBeDefined() + expect(fetcher).not.toHaveBeenCalled() + + await enricher!.prepare!(['gemma3']) + + expect(fetcher).toHaveBeenCalledWith('http://127.0.0.1:11434/api/show', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'gemma3' }), + })) + expect(enricher!.enrich({ id: 'gemma3' }, { filterNonChat: true })).toMatchObject({ + limit: { context: 8_192 }, + toolCall: true, + }) + }) + it('resolves remote LiteLLM format with authentication and timeout', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: true, diff --git a/test/core/model-enrichment.test.ts b/test/core/model-enrichment.test.ts index d4a8d6d..7133b10 100644 --- a/test/core/model-enrichment.test.ts +++ b/test/core/model-enrichment.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { enrichModelDraft } from '../../src/core/model-enrichment' +import { describe, expect, it, vi } from 'vitest' +import { enrichModelDraft, prepareModelEnricher } from '../../src/core/model-enrichment' describe('shared model enrichment contract', () => { it('applies native enricher output into a neutral draft', () => { @@ -39,4 +39,32 @@ describe('shared model enrichment contract', () => { expect(result.skipped).toBe(true) expect(applied).toBe(true) }) + + it('runs deferred preparation before enrichment', async () => { + const prepare = vi.fn(async () => {}) + const enricher = { + prepare, + enrich: (model: { id: string }) => ({ limit: { context: model.id.length } }), + } + + await prepareModelEnricher(enricher, ['alpha', 'beta']) + + expect(prepare).toHaveBeenCalledOnce() + expect(prepare).toHaveBeenCalledWith(['alpha', 'beta']) + const result = enrichModelDraft({ id: 'alpha', name: 'alpha', raw: { id: 'alpha' } }, enricher) + expect(result.draft.limit).toEqual({ context: 5 }) + }) + + it('treats deferred preparation failures as best-effort', async () => { + const prepare = vi.fn(async () => { + throw new Error('metadata unavailable') + }) + + await expect(prepareModelEnricher({ prepare, enrich: () => ({}) }, ['alpha'])).resolves.toBeUndefined() + }) + + it('skips preparation for enrichers without a deferred phase', async () => { + await expect(prepareModelEnricher({ enrich: () => ({}) }, ['alpha'])).resolves.toBeUndefined() + await expect(prepareModelEnricher(undefined, ['alpha'])).resolves.toBeUndefined() + }) }) diff --git a/test/fixtures/ollama/show-gemma.json b/test/fixtures/ollama/show-gemma.json new file mode 100644 index 0000000..0218b42 --- /dev/null +++ b/test/fixtures/ollama/show-gemma.json @@ -0,0 +1,17 @@ +{ + "model": "gemma4-e2b", + "modified_at": "2026-10-01T00:00:00Z", + "details": { + "family": "gemma4", + "parameter_size": "2B", + "quantization_level": "Q4_K_M" + }, + "parameters": "num_ctx 131072", + "model_info": { + "general.architecture": "gemma4", + "gemma4.context_length": 131072, + "gemma4.embedding_length": 2048, + "distractor.context_length": 4096 + }, + "capabilities": ["completion", "vision", "tools", "thinking"] +} diff --git a/test/ollama-model-info.test.ts b/test/ollama-model-info.test.ts new file mode 100644 index 0000000..4ec3770 --- /dev/null +++ b/test/ollama-model-info.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest' +import { createOllamaEnricher } from '../src/utils/model-info/ollama' +import { prepareModelEnricher } from '../src/core/model-enrichment' +import showGemma from './fixtures/ollama/show-gemma.json' + +const BASE_URL = 'http://127.0.0.1:11434/v1' + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response +} + +async function enrichedResult(body: unknown, filterNonChat = true) { + const fetcher = vi.fn().mockResolvedValue(jsonResponse(body)) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + await prepareModelEnricher(enricher, ['model']) + return enricher!.enrich({ id: 'model' }, { filterNonChat }) +} + +describe('Ollama model info enricher', () => { + it('posts each discovered id to /api/show and maps context and capabilities', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse(showGemma)) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + + await prepareModelEnricher(enricher, ['gemma4-e2b']) + + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledWith('http://127.0.0.1:11434/api/show', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'gemma4-e2b' }), + })) + + const result = enricher!.enrich({ id: 'gemma4-e2b' }, { filterNonChat: true }) + expect(result).toEqual({ + limit: { context: 131_072, output: 32_000 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + reasoning: true, + toolCall: true, + }) + }) + + it('prefers num_ctx over architecture-derived context_length', async () => { + const result = await enrichedResult({ + ...showGemma, + parameters: 'num_ctx 64000', + }) + + expect(result.limit?.context).toBe(64_000) + }) + + it('falls back to the architecture context_length when num_ctx is absent', async () => { + const result = await enrichedResult({ + ...showGemma, + parameters: '', + }) + + expect(result.limit?.context).toBe(131_072) + }) + + it('falls back to any available *.context_length entry', async () => { + const result = await enrichedResult({ + model_info: { + 'other.context_length': 4_096, + 'second.context_length': 8_192, + }, + }) + + expect(result.limit?.context).toBe(8_192) + }) + + it('falls back to a top-level context_length and ignores a non-positive num_ctx', async () => { + const result = await enrichedResult({ + parameters: 'num_ctx 0', + context_length: 32_768, + }) + + expect(result.limit?.context).toBe(32_768) + }) + + it('parses the quoted and colon-separated num_ctx variants', async () => { + await expect(enrichedResult({ parameters: 'num_ctx: 8192' })).resolves.toMatchObject({ limit: { context: 8_192 } }) + await expect(enrichedResult({ parameters: '"num_ctx": 16384' })).resolves.toMatchObject({ limit: { context: 16_384 } }) + }) + + it('maps audio and image capabilities, reasoning, and tool calling', async () => { + const result = await enrichedResult({ + capabilities: ['completion', 'audio', 'vision', 'thinking', 'tools'], + }) + + expect(result).toEqual({ + modalities: { input: ['text', 'image', 'audio'], output: ['text'] }, + reasoning: true, + toolCall: true, + }) + }) + + it('supports images and function_calling aliases', async () => { + const result = await enrichedResult({ + capabilities: ['completion', 'images', 'function_calling'], + }) + + expect(result).toEqual({ + modalities: { input: ['text', 'image'], output: ['text'] }, + toolCall: true, + }) + }) + + it('skips embedding-only models when filterNonChat is enabled', async () => { + const body = { + parameters: 'num_ctx 8192', + capabilities: ['embedding'], + } + + await expect(enrichedResult(body, true)).resolves.toEqual({ skip: true }) + await expect(enrichedResult(body, false)).resolves.toMatchObject({ limit: { context: 8_192 } }) + }) + + it('does not skip a model that reports both completion and embedding', async () => { + const result = await enrichedResult({ + capabilities: ['completion', 'embedding'], + }) + + expect(result.skip).toBeUndefined() + }) + + it('degrades per model when /api/show rejects or fails', async () => { + const warn = vi.fn() + const fetcher = vi.fn(async (_url: string, init?: RequestInit) => { + const model = JSON.parse(String(init?.body)).model + if (model === 'good') return jsonResponse(showGemma) + if (model === 'missing') return jsonResponse({}, 404) + throw new Error('timeout') + }) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher, logger: { warn } }) + + await expect(prepareModelEnricher(enricher, ['good', 'missing', 'boom'])).resolves.toBeUndefined() + + expect(enricher!.enrich({ id: 'good' }, { filterNonChat: true }).limit?.context).toBe(131_072) + expect(enricher!.enrich({ id: 'missing' }, { filterNonChat: true })).toEqual({}) + expect(enricher!.enrich({ id: 'boom' }, { filterNonChat: true })).toEqual({}) + expect(warn).toHaveBeenCalledTimes(2) + }) + + it('keeps an enricher that no-ops when every request fails', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('offline')) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + + await prepareModelEnricher(enricher, ['alpha', 'beta']) + + expect(enricher).toBeDefined() + expect(enricher!.enrich({ id: 'alpha' }, { filterNonChat: true })).toEqual({}) + }) + + it('bounds concurrency instead of issuing all requests at once', async () => { + let active = 0 + let peak = 0 + const fetcher = vi.fn(async () => { + active += 1 + peak = Math.max(peak, active) + await new Promise((resolve) => setTimeout(resolve, 5)) + active -= 1 + return jsonResponse({ capabilities: ['completion'] }) + }) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + + await enricher!.prepare!(Array.from({ length: 10 }, (_, index) => `model-${index}`)) + + expect(fetcher).toHaveBeenCalledTimes(10) + expect(peak).toBeLessThanOrEqual(4) + }) + + it('honors an absolute modelInfoEndpoint override', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse(showGemma)) + const enricher = createOllamaEnricher({ + baseURL: BASE_URL, + endpoint: 'http://proxy.example/api/show', + fetcher, + }) + + await prepareModelEnricher(enricher, ['gemma4-e2b']) + + expect(fetcher).toHaveBeenCalledWith('http://proxy.example/api/show', expect.anything()) + }) + + it('does not refetch ids that were already prepared', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse(showGemma)) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + + await enricher!.prepare!(['gemma4-e2b', 'gemma4-e2b']) + await enricher!.prepare!(['gemma4-e2b']) + + expect(fetcher).toHaveBeenCalledOnce() + }) + + it('does not enrich unknown models', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponse(showGemma)) + const enricher = createOllamaEnricher({ baseURL: BASE_URL, fetcher }) + + await prepareModelEnricher(enricher, ['gemma4-e2b']) + + expect(enricher!.enrich({ id: 'other' }, { filterNonChat: true })).toEqual({}) + }) +}) diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index fe2f494..6bbe41c 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -15,6 +15,7 @@ describe('JSON config struct parsing', () => { { json: '{"modelInfoFormat":"vllm"}', expected: true }, { json: '{"modelInfoFormat":"llama-swap"}', expected: true }, { json: '{"modelInfoFormat":"omniroute"}', expected: true }, + { json: '{"modelInfoFormat":"ollama"}', expected: true }, { json: '{"modelInfoFormat":"bogus"}', expected: false }, ])('handles modelInfoFormat=$json', ({ json, expected }) => { const config = parse(json) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index a48e23a..b102575 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -282,6 +282,7 @@ describe('ModelDiscovery Plugin', () => { 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('modelInfoFormat="ollama"') expect(config.command['models-discovery:config'].template).toContain('max_model_len') expect(config.command['models-discovery:config'].template).toContain('restart opencode') @@ -2698,6 +2699,108 @@ describe('ModelDiscovery Plugin', () => { expect(config.provider.lmstudio.models['generic-model'].limit).toBeUndefined() }) + it('enriches Ollama models from /api/show after the model list is known', async () => { + mockFetch.mockImplementation(async (url: string, init: any) => { + if (url.endsWith('/v1/models')) { + return { + ok: true, + json: async () => ({ + data: [ + { id: 'gemma4-e2b', object: 'model', created: 0, owned_by: 'library' }, + { id: 'nomic-text', object: 'model', created: 0, owned_by: 'library' }, + ], + }), + } + } + if (url.endsWith('/api/show')) { + const model = JSON.parse(init.body).model + if (model === 'gemma4-e2b') { + return { + ok: true, + json: async () => ({ + details: { family: 'gemma4' }, + parameters: 'num_ctx 131072', + model_info: { + 'general.architecture': 'gemma4', + 'gemma4.context_length': 131072, + }, + capabilities: ['completion', 'vision', 'tools', 'thinking'], + }), + } + } + return { + ok: true, + json: async () => ({ + parameters: 'num_ctx 2048', + capabilities: ['embedding'], + }), + } + } + return { ok: false } + }) + const config: any = { + provider: { + ollama: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: 'http://127.0.0.1:11434/v1', + modelsDiscovery: { modelInfoFormat: 'ollama' }, + }, + models: {}, + }, + }, + } + + await pluginHooks.config(config) + + expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:11434/api/show', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'gemma4-e2b' }), + })) + expect(config.provider.ollama.models['gemma4-e2b']).toMatchObject({ + id: 'gemma4-e2b', + limit: { context: 131072, output: 32000 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + reasoning: true, + tool_call: true, + }) + expect(config.provider.ollama.models['nomic-text']).toBeUndefined() + }) + + it('keeps generic Ollama discovery when /api/show metadata is unavailable', async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.endsWith('/v1/models')) { + return { + ok: true, + json: async () => ({ data: [{ id: 'generic-model', object: 'model', created: 0, owned_by: 'library' }] }), + } + } + if (url.endsWith('/api/show')) { + return { ok: false, status: 404, json: async () => ({}) } + } + return { ok: false } + }) + const config: any = { + provider: { + ollama: { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: 'http://127.0.0.1:11434/v1', + modelsDiscovery: { modelInfoFormat: 'ollama' }, + }, + models: {}, + }, + }, + } + + await pluginHooks.config(config) + + expect(config.provider.ollama.models['generic-model']).toMatchObject({ + id: 'generic-model', + }) + expect(config.provider.ollama.models['generic-model'].limit).toBeUndefined() + }) + it('reuses enriched LM Studio models from a fresh cache without requests', async () => { mockFetch.mockImplementation(async (url: string) => { if (url.endsWith('/api/v1/models')) { diff --git a/test/v2/enricher-prepare.test.ts b/test/v2/enricher-prepare.test.ts new file mode 100644 index 0000000..ffba32a --- /dev/null +++ b/test/v2/enricher-prepare.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const prepareModelEnricher = vi.hoisted(() => vi.fn(async () => {})) + +vi.mock("../../src/core/model-enrichment.js", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, prepareModelEnricher } +}) + +import { discoverInventory } from "../../src/v2/discovery.js" +import { parseProviderDiscoveryOptions } from "../../src/v2/provider-config.js" +import { ModelInfoFormat } from "../../src/types/plugin-config.js" + +const provider = { + id: "local", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: "http://127.0.0.1:1234/v1" }, +} + +describe("V2 deferred enricher preparation", () => { + beforeEach(() => { + prepareModelEnricher.mockClear() + }) + + it("prepares a resolved enricher with the discovered model ids", async () => { + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "alpha" }, { id: "beta" }] }), + }) + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + modelInfoFormat: ModelInfoFormat.Bifrost, + })!], + ]) + + await discoverInventory([provider], options, fetcher as unknown as typeof fetch) + + expect(prepareModelEnricher).toHaveBeenCalledOnce() + expect(prepareModelEnricher).toHaveBeenCalledWith( + expect.objectContaining({ enrich: expect.any(Function) }), + ["alpha", "beta"], + ) + }) + + it("does not prepare again on a fresh cache hit", async () => { + const cache = new Map() + const storage = { + get: vi.fn(async (key: string) => cache.get(key)), + set: vi.fn(async (key: string, value: unknown) => { cache.set(key, value) }), + } + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: "alpha" }] }), + }) + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + modelInfoFormat: ModelInfoFormat.Bifrost, + cache: { enabled: true }, + })!], + ]) + + await discoverInventory([provider], options, fetcher as unknown as typeof fetch, storage) + await discoverInventory([provider], options, fetcher as unknown as typeof fetch, storage) + + expect(prepareModelEnricher).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledOnce() + }) +}) diff --git a/test/v2/enrichment.test.ts b/test/v2/enrichment.test.ts index 0681c45..632a54a 100644 --- a/test/v2/enrichment.test.ts +++ b/test/v2/enrichment.test.ts @@ -43,6 +43,105 @@ describe("V2 provider discovery with metadata enrichment", () => { expect((model as any).reasoning).toBe(true) }) + it("enriches Ollama models from /api/show after the model list is known", async () => { + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + smartModelName: true, + modelInfoFormat: ModelInfoFormat.Ollama, + })!], + ]) + + const fetcher = vi.fn(async (url: string, init?: RequestInit) => { + if (url.endsWith("/v1/models")) { + return { + ok: true, + json: async () => ({ + data: [ + { id: "gemma4-e2b" }, + { id: "nomic-text" }, + ], + }), + } as Response + } + + const model = JSON.parse(String(init?.body)).model + if (model === "gemma4-e2b") { + return { + ok: true, + json: async () => ({ + details: { family: "gemma4" }, + parameters: "num_ctx 131072", + model_info: { + "general.architecture": "gemma4", + "gemma4.context_length": 131072, + }, + capabilities: ["completion", "vision", "tools", "thinking"], + }), + } as Response + } + return { + ok: true, + json: async () => ({ + parameters: "num_ctx 2048", + capabilities: ["embedding"], + }), + } as Response + }) + + const inventory = await discoverInventory([{ + id: "local", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: "http://127.0.0.1:11434/v1" }, + }], options, fetcher as unknown as typeof fetch) + + expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:11434/api/show", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ model: "gemma4-e2b" }), + })) + + const models = inventory.get("local")! + expect(models.has("nomic-text")).toBe(false) + expect(models.get("gemma4-e2b")).toMatchObject({ + limit: { context: 131_072, output: 32_000 }, + capabilities: { input: ["text", "image"], output: ["text"] }, + reasoning: true, + }) + }) + + it("keeps default limits when every Ollama /api/show request fails", async () => { + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + modelInfoFormat: ModelInfoFormat.Ollama, + })!], + ]) + + const fetcher = vi.fn(async (url: string) => { + if (url.endsWith("/v1/models")) { + return { + ok: true, + json: async () => ({ data: [{ id: "gemma4-e2b" }] }), + } as Response + } + return { + ok: false, + status: 404, + json: async () => ({}), + } as Response + }) + + const inventory = await discoverInventory([{ + id: "local", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: "http://127.0.0.1:11434/v1" }, + }], options, fetcher as unknown as typeof fetch) + + expect(inventory.get("local")?.get("gemma4-e2b")).toMatchObject({ + limit: { context: 200_000, output: 32_000 }, + }) + }) + it("queries external litellm endpoint and filters non-chat models", async () => { const options = new Map([ ["local", parseProviderDiscoveryOptions({