diff --git a/README.md b/README.md index 975ac08..3ae965c 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 `ollama`. `models.dev` fetches `https://models.dev/models.json` by default; `modelInfoEndpoint` can override the catalog URL. The `aiproxy` format composes that catalog with inline metadata from AIProxy's model list. `filterNonChat` defaults to `true`; it can filter non-chat models when the selected metadata source supplies that information. `timeoutMs` defaults to 5000 ms. Models returned by discovery are added alongside explicitly configured models. For a local build, run `npm run compile` and replace the package value above with `"file:///absolute/path/to/opencode-models-discovery/dist"`. OpenCode v2 expects a **directory**, not a path to `index.js` or `server.js`. After rebuilding plugin code, run `opencode service restart` so the background service loads the new bundle. Discovery runs when the plugin initializes. The native TUI commands are `/models-discovery-refresh` and `/models-discovery-console`; the console combines Provider → model → model details browsing with cache inspection and clearing. Use `/models-discovery-console status --provider ` 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..8c805fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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"`, and `"omniroute"` | +| `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"`, 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 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 | +| `"ollama"` | Ollama native `POST /api/show` | No | Uses `/api/show` by default; set `modelInfoEndpoint` to override | ### AIProxy Metadata @@ -604,6 +605,34 @@ Only models returned by `/v1/models` are injected. A model is enriched only when When available, the plugin sets `limit.context` from the largest loaded instance `config.context_length`, otherwise it uses `max_context_length`. LM Studio does not report a distinct output limit, so the plugin resolves a safe default output limit bounded by the context window and capped at OpenCode's default 32,000 output tokens. The plugin maps `capabilities.vision` to image input and `capabilities.trained_for_tool_use` to `tool_call`. Its reported reasoning options are the source of truth for variants: `off`, `low`, `medium`, `high`, and `xhigh` become variants, with `off` sent as `reasoningEffort: "none"`; `on` and unknown options are omitted because they are not concrete OpenAI-compatible efforts. Missing or malformed metadata is left unset without preventing discovery. +### Ollama Model Show + +Use `modelInfoFormat: "ollama"` with Ollama's OpenAI-compatible endpoint. Ollama's `GET /v1/models` response carries only the minimal model list shape, so the plugin enriches each discovered model with a native `POST /api/show` request (`{"model": ""}`) after discovery. Set `modelInfoEndpoint` only when Ollama uses another show path. + +```json +{ + "plugin": ["opencode-models-discovery"], + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama", + "options": { + "baseURL": "http://localhost:11434/v1", + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "ollama" + } + }, + "models": {} + } + } +} +``` + +Only models returned by `/v1/models` are injected, and each one receives its own `/api/show` request in parallel. Each model is enriched from the `/api/show` response stored under the exact id the plugin requested; a failed or malformed per-model response degrades to no enrichment for that model without preventing discovery. + +The context limit is resolved in strict order: a valid `num_ctx` value parsed from the `parameters` string first — a `num_ctx` set in the model's Modelfile is Ollama's only intentional effective-context signal, so it wins over the architecture-derived maxima — then `model_info..context_length`, then any other `model_info` key ending in `.context_length`, then a top-level `context_length`. A missing or non-positive `num_ctx` is ignored and the chain applies; the pinned value is not clamped to the architecture maximum. The output limit is bounded by the context window and capped at OpenCode's default 32,000 output tokens. Ollama `capabilities` map as follows: `vision` or `images` add image input, `audio` adds audio input, `thinking` sets `reasoning`, and `tools`, `function_calling`, or `tool_calling` set `tool_call`. Unknown capabilities and missing fields are ignored without inventing metadata. `filterNonChat` has no effect for this format; Ollama's show response exposes no chat-mode field. + ### models.dev Metadata Use `modelInfoFormat: "models.dev"` to enrich discovered models from the public [models.dev](https://models.dev) metadata index. diff --git a/docs/providers.md b/docs/providers.md index eaecd28..b9f6733 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -193,3 +193,31 @@ This project is not affiliated with, endorsed by, or sponsored by [models.dev](h When a safe model id segment match exists, models.dev metadata can add model limits and capabilities such as `tool_call`, `reasoning`, `attachment`, `structured_output`, `temperature`, and `modalities`. This means providers using `@ai-sdk/anthropic` with OpenAI-compatible backends are supported when the `baseURL` contains `/v1/`, when a provider-specific discovery endpoint is configured, or when provider-level discovery is explicitly forced on. It also means providers like DeepSeek can be discovered from a non-`/v1` baseURL as long as the models endpoint is configured explicitly. + +### Ollama Metadata Enrichment + +Ollama exposes an OpenAI-compatible `GET /v1/models` endpoint that returns only the minimal model list shape — no context limits and no capabilities. Ollama's native API provides both through `POST /api/show` with a `{"model": ""}` body, so the plugin supports an explicit Ollama metadata format: + +```json +{ + "plugin": ["opencode-models-discovery"], + "provider": { + "ollama": { + "npm": "@ai-sdk/openai-compatible", + "name": "Ollama", + "options": { + "baseURL": "http://localhost:11434/v1", + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "ollama" + } + }, + "models": {} + } + } +} +``` + +This setting is explicit because the plugin issues one additional `POST /api/show` request per discovered model after the `/v1/models` fetch. Without `modelInfoFormat: "ollama"`, the plugin does not contact `/api/show`. + +For each discovered model, the plugin resolves the context limit from a valid `num_ctx` in the show body's `parameters` string first (the Modelfile's explicit context pin; missing or non-positive values are ignored), falling back to `model_info..context_length`, then any other `*.context_length` key, then a top-level `context_length`; and maps Ollama `capabilities` to modalities, `reasoning`, and `tool_call`. A per-model failure degrades to no enrichment for that model only; discovery continues for the rest. diff --git a/src/core/enricher-resolver.ts b/src/core/enricher-resolver.ts index d1d9e5b..8cfc8e4 100644 --- a/src/core/enricher-resolver.ts +++ b/src/core/enricher-resolver.ts @@ -4,6 +4,7 @@ import { fetchModelsDevData, DEFAULT_MODELS_DEV_URL } from '../utils/models-dev- export const DEFAULT_LITELLM_ENDPOINT = '/v1/model/info' export const DEFAULT_LMSTUDIO_ENDPOINT = '/api/v1/models' +export const DEFAULT_OLLAMA_ENDPOINT = '/api/show' export const DEFAULT_ENRICHER_TIMEOUT_MS = 5000 export interface ModelInfoResolutionOptions { @@ -11,6 +12,7 @@ export interface ModelInfoResolutionOptions { readonly endpoint?: string readonly filterNonChat?: boolean readonly timeoutMs?: number + readonly modelIds?: readonly string[] } export interface ModelInfoResolverLogger { @@ -78,6 +80,65 @@ export async function resolveModelInfoEnricher( return createModelEnricher(format, null) } + if (format === ModelInfoFormat.Ollama) { + const modelIds = options.modelIds ?? [] + if (modelIds.length === 0) { + context.logger?.warn('Ollama model info format requires model IDs', { + provider: context.providerName, + }) + return undefined + } + + const targetEndpoint = options.endpoint ?? DEFAULT_OLLAMA_ENDPOINT + const showUrl = buildModelInfoURL(context.baseURL, targetEndpoint) + const fetcher = context.fetcher ?? globalThis.fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_ENRICHER_TIMEOUT_MS + + const headers = new Headers({ accept: 'application/json', 'content-type': 'application/json' }) + if (context.apiKey) { + headers.set('authorization', `Bearer ${context.apiKey}`) + } + + const results = await Promise.all(modelIds.map(async (modelId): Promise => { + try { + const res = await fetcher(showUrl, { + method: 'POST', + headers, + body: JSON.stringify({ model: modelId }), + signal: AbortSignal.timeout(timeoutMs), + }) + if (!res.ok) { + context.logger?.warn('Provider model info discovery failed', { + provider: context.providerName, + endpoint: targetEndpoint, + format, + modelId, + status: res.status, + }) + return undefined + } + return [modelId, await res.json()] as const + } catch (error) { + context.logger?.warn('Provider model info discovery failed', { + provider: context.providerName, + endpoint: targetEndpoint, + format, + modelId, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + })) + + const shows = new Map() + for (const entry of results) { + if (entry) shows.set(entry[0], entry[1]) + } + if (shows.size === 0) return undefined + + return createModelEnricher(format, shows, { filterNonChat }) + } + if (format === ModelInfoFormat.LiteLLM || format === ModelInfoFormat.LMStudio) { const defaultEndpoint = format === ModelInfoFormat.LiteLLM ? DEFAULT_LITELLM_ENDPOINT : DEFAULT_LMSTUDIO_ENDPOINT const targetEndpoint = options.endpoint ?? defaultEndpoint diff --git a/src/plugin/commands.ts b/src/plugin/commands.ts index 2b16f64..95fbea5 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="ollama": enrich exact model-id matches from Ollama's POST /api/show endpoint; modelInfoEndpoint optionally overrides the path - filterNonChat: when LiteLLM model info is available, skip non-chat models by default - cache.enabled: opt in to a provider-scoped persisted discovery cache of filtered and enriched model configurations; defaults to false - cache.ttlSeconds: non-negative finite cache lifetime in seconds; defaults to 86400 @@ -153,6 +154,7 @@ Recommended defaults: - use modelInfoFormat="lmstudio" for LM Studio REST metadata enrichment; set modelInfoEndpoint only when LM Studio uses another inventory path - use modelInfoFormat="llama-swap" only for llama-swap /v1/models responses; it is an explicit inline-metadata format and does not make another request - use modelInfoFormat="omniroute" only for OmniRoute's documented /v1/models response; it is an explicit inline-metadata format and does not make another request +- use modelInfoFormat="ollama" for Ollama-native metadata enrichment; set modelInfoEndpoint only when Ollama uses another path - configure caching only when the user requests it; it is disabled by default - the persisted discovery cache belongs to this plugin and is not a replacement for opencode.json diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index d8404c8..a9f0def 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -339,6 +339,7 @@ export async function enhanceConfig( endpoint: providerDiscoveryConfig.modelInfoEndpoint, filterNonChat, timeoutMs, + modelIds: models.filter(isDiscoveredRawModel).map((model) => model.id), }) } if (reprocessPersistedModels) { diff --git a/src/types/plugin-config.ts b/src/types/plugin-config.ts index fd4105f..90a8d64 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', + 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 79350df..e4d5c12 100644 --- a/src/utils/model-info/index.ts +++ b/src/utils/model-info/index.ts @@ -1,6 +1,7 @@ import { createBifrostEnricher } from './bifrost' import { createLiteLLMEnricher } from './litellm' import { createLMStudioEnricher } from './lmstudio' +import { createOllamaEnricher } from './ollama' import { createLlamaSwapEnricher } from './llamaswap' import { createModelsDevEnricher } from './models-dev' import { createOmniRouteEnricher } from './omniroute' @@ -20,6 +21,7 @@ const MODEL_ENRICHERS: Partial> = [ModelInfoFormat.LMStudio]: (data) => createLMStudioEnricher(data), [ModelInfoFormat.LiteLLM]: (data) => createLiteLLMEnricher(data), [ModelInfoFormat.AIProxy]: (data) => createAIProxyEnricher(data), + [ModelInfoFormat.Ollama]: (data) => createOllamaEnricher(data), } /** Creates the neutral contract used by the shared discovery pipeline. */ diff --git a/src/utils/model-info/ollama.ts b/src/utils/model-info/ollama.ts new file mode 100644 index 0000000..3eade2f --- /dev/null +++ b/src/utils/model-info/ollama.ts @@ -0,0 +1,88 @@ +import type { ModelEnricher } from '../../core/model-enrichment' +import { createModelLimits } from '../../core/model-types' + +function hasUsableNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + +function getRecordString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function resolveOllamaContextLength(show: Record): number | undefined { + const parameters = getRecordString(show, 'parameters') + if (parameters) { + const match = /"?num_ctx"?[:=\s]+(\d+)/.exec(parameters) + const parsed = match ? Number(match[1]) : undefined + if (hasUsableNumber(parsed)) return parsed + } + + const details = asRecord(show.details) + const family = details ? getRecordString(details, 'family') : undefined + const modelInfo = asRecord(show.model_info) + if (modelInfo) { + if (family) { + const familyValue = modelInfo[`${family}.context_length`] + if (hasUsableNumber(familyValue)) return familyValue + } + for (const [key, value] of Object.entries(modelInfo)) { + if (key.endsWith('.context_length') && hasUsableNumber(value)) return value + } + } + + if (hasUsableNumber(show.context_length)) return show.context_length + + return undefined +} + +export function createOllamaEnricher(data: unknown): ModelEnricher { + const shows = new Map() + if (data instanceof Map) { + for (const [key, value] of data) { + if (typeof key === 'string' && key.length > 0 && value && typeof value === 'object') { + shows.set(key, value) + } + } + } + + return { + enrich(model) { + const show = asRecord(shows.get(model.id)) + if (!show) return {} + + const result: { + limit?: { context: number; output: number } + modalities?: { input: string[]; output: string[] } + toolCall?: boolean + reasoning?: boolean + } = {} + + const contextLength = resolveOllamaContextLength(show) + const limits = createModelLimits(contextLength) + if (limits) result.limit = limits + + const capabilities = Array.isArray(show.capabilities) ? show.capabilities : [] + const imageInput = capabilities.includes('vision') || capabilities.includes('images') + const audioInput = capabilities.includes('audio') + if (imageInput || audioInput) { + result.modalities = { + input: ['text', ...(imageInput ? ['image'] : []), ...(audioInput ? ['audio'] : [])], + output: ['text'], + } + } + if (capabilities.includes('thinking')) result.reasoning = true + if (capabilities.includes('tools') || capabilities.includes('function_calling') || capabilities.includes('tool_calling')) { + result.toolCall = true + } + + return result + }, + } +} diff --git a/src/v2/discovery.ts b/src/v2/discovery.ts index 6c5d7e6..ed278c5 100644 --- a/src/v2/discovery.ts +++ b/src/v2/discovery.ts @@ -3,9 +3,11 @@ 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 { isDiscoveredRawModel } from "../core/model-types.js" import type { ModelEnrichmentResult, 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" +import { ModelInfoFormat } from "../types/plugin-config.js" export interface DiscoveryLogger { warn(message: string, context?: Record): void @@ -44,6 +46,11 @@ function cachedEnricher(enrichments: Record): Mod } } +function parseModelsPayload(payload: unknown): readonly unknown[] | undefined { + const data = payload && typeof payload === "object" ? (payload as { data?: unknown }).data : undefined + return Array.isArray(data) ? data : undefined +} + export async function discoverInventory( providers: readonly CatalogProvider[], discovery: ReadonlyMap, @@ -95,6 +102,49 @@ export async function discoverInventory( if (cached) { rawModels = cached.rawModels enricher = cachedEnricher(cached.enrichments) + } else if (config.modelInfoFormat === ModelInfoFormat.Ollama) { + const modelsResponse = await fetcher(url, { + headers, + signal: AbortSignal.timeout(config.timeoutMs), + }) + if (!modelsResponse.ok) { + logger.warn(`Model discovery request failed for provider "${provider.id}"`, { + provider: provider.id, + url, + status: modelsResponse.status, + statusText: modelsResponse.statusText, + }) + return + } + + const payload = await modelsResponse.json() as { data?: unknown } + const models = parseModelsPayload(payload) + if (!models) { + logger.warn(`Model discovery returned invalid payload for provider "${provider.id}": expected array in "data" property`, { + provider: provider.id, + url, + payloadType: typeof payload, + keys: payload && typeof payload === "object" && !Array.isArray(payload) ? Object.keys(payload) : undefined, + }) + return + } + rawModels = models + enricher = await resolveModelInfoEnricher({ + baseURL, + apiKey: resolvedApiKey, + providerName: provider.id, + fetcher, + logger: { + warn: (message, context) => logger.warn(message, context), + info: (message, context) => logger.info?.(message, context), + }, + }, { + format: config.modelInfoFormat, + endpoint: config.modelInfoEndpoint, + filterNonChat: config.filterNonChat, + timeoutMs: config.timeoutMs, + modelIds: models.filter(isDiscoveredRawModel).map((model) => model.id), + }) } else { const [modelsResponse, resolvedEnricher] = await Promise.all([ fetcher(url, { @@ -129,7 +179,8 @@ export async function discoverInventory( } const payload = await modelsResponse.json() as { data?: unknown } - if (!Array.isArray(payload?.data)) { + const models = parseModelsPayload(payload) + if (!models) { logger.warn(`Model discovery returned invalid payload for provider "${provider.id}": expected array in "data" property`, { provider: provider.id, url, @@ -138,7 +189,7 @@ export async function discoverInventory( }) return } - rawModels = payload.data + rawModels = models enricher = resolvedEnricher } diff --git a/test/core/enricher-resolver.test.ts b/test/core/enricher-resolver.test.ts index 2b0431b..a8e6e5f 100644 --- a/test/core/enricher-resolver.test.ts +++ b/test/core/enricher-resolver.test.ts @@ -77,3 +77,166 @@ describe('shared enricher resolver', () => { })) }) }) + +describe('shared enricher resolver: Ollama format', () => { + const showBody = (contextLength: number) => ({ + details: { family: 'gemma3' }, + model_info: { 'gemma3.context_length': contextLength }, + capabilities: ['tools'], + }) + + it('resolves an Ollama enricher with per-model POST requests', async () => { + const fetcher = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string } + return { + ok: true, + json: async () => showBody(body.model === 'gemma3' ? 131072 : 8192), + } + }) + + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher }, + { format: ModelInfoFormat.Ollama, modelIds: ['gemma3', 'llama3'] }, + ) + + expect(fetcher).toHaveBeenCalledWith('http://127.0.0.1:11434/api/show', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ model: 'gemma3' }), + })) + expect(enricher).toBeDefined() + expect(enricher?.enrich({ id: 'gemma3' }, { filterNonChat: true }).limit).toEqual({ context: 131072, output: 32000 }) + expect(enricher?.enrich({ id: 'llama3' }, { filterNonChat: true }).limit).toEqual({ context: 8192, output: 8192 }) + }) + + it('tolerates a per-model 404 without crashing discovery', async () => { + const warn = vi.fn() + const fetcher = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string } + if (body.model === 'b') return { ok: false, status: 404 } + return { ok: true, json: async () => showBody(4096) } + }) + + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: ['a', 'b'] }, + ) + + expect(enricher).toBeDefined() + expect(enricher?.enrich({ id: 'a' }, { filterNonChat: true }).limit).toBeDefined() + expect(enricher?.enrich({ id: 'b' }, { filterNonChat: true })).toEqual({}) + expect(warn).toHaveBeenCalledWith('Provider model info discovery failed', expect.objectContaining({ + modelId: 'b', + status: 404, + })) + }) + + it('degrades a per-model ok response with malformed JSON without crashing discovery', async () => { + const warn = vi.fn() + const fetcher = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string } + if (body.model === 'bad') { + return { + ok: true, + json: async () => { + throw new SyntaxError('Unexpected token in JSON') + }, + } + } + return { ok: true, json: async () => showBody(4096) } + }) + + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: ['good', 'bad'] }, + ) + + expect(enricher).toBeDefined() + expect(enricher?.enrich({ id: 'bad' }, { filterNonChat: true })).toEqual({}) + expect(enricher?.enrich({ id: 'good' }, { filterNonChat: true }).limit).toEqual({ context: 4096, output: 4096 }) + expect(warn).toHaveBeenCalledWith('Provider model info discovery failed', expect.objectContaining({ + modelId: 'bad', + error: 'Unexpected token in JSON', + })) + }) + + it('returns undefined when all per-model POSTs fail', async () => { + const warn = vi.fn() + const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 404 }) + + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: ['a', 'b'] }, + ) + + expect(enricher).toBeUndefined() + expect(warn).toHaveBeenCalledWith('Provider model info discovery failed', expect.objectContaining({ + modelId: 'a', + })) + }) + + it('returns undefined without fetching when modelIds are empty or omitted', async () => { + const warn = vi.fn() + const fetcher = vi.fn() + + const empty = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: [] }, + ) + const omitted = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama }, + ) + + expect(empty).toBeUndefined() + expect(omitted).toBeUndefined() + expect(fetcher).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith('Ollama model info format requires model IDs', expect.objectContaining({})) + }) + + it('propagates per-model rejections as per-model no-enrichment', async () => { + const warn = vi.fn() + const fetcher = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { model: string } + if (body.model === 'bad') throw new Error('network error') + return { ok: true, json: async () => showBody(4096) } + }) + + const mixed = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: ['good', 'bad'] }, + ) + expect(mixed).toBeDefined() + expect(mixed?.enrich({ id: 'good' }, { filterNonChat: true }).limit).toBeDefined() + expect(warn).toHaveBeenCalledWith('Provider model info discovery failed', expect.objectContaining({ + modelId: 'bad', + error: 'network error', + })) + + warn.mockClear() + const failing = vi.fn().mockRejectedValue(new Error('network error')) + const allFailed = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher: failing, logger: { warn } }, + { format: ModelInfoFormat.Ollama, modelIds: ['a'] }, + ) + expect(allFailed).toBeUndefined() + expect(warn).toHaveBeenCalledWith('Provider model info discovery failed', expect.objectContaining({ + error: 'network error', + })) + }) + + it('uses the default /api/show endpoint and honors a custom modelInfoEndpoint', async () => { + const defaultFetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => showBody(4096) }) + await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher: defaultFetcher }, + { format: ModelInfoFormat.Ollama, modelIds: ['a'] }, + ) + expect(defaultFetcher).toHaveBeenCalledWith('http://127.0.0.1:11434/api/show', expect.anything()) + + const customFetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => showBody(4096) }) + await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher: customFetcher }, + { format: ModelInfoFormat.Ollama, modelIds: ['a'], endpoint: '/api/custom' }, + ) + expect(customFetcher).toHaveBeenCalledWith('http://127.0.0.1:11434/api/custom', expect.anything()) + }) +}) diff --git a/test/fixtures/ollama/show-gemma.json b/test/fixtures/ollama/show-gemma.json new file mode 100644 index 0000000..fce1139 --- /dev/null +++ b/test/fixtures/ollama/show-gemma.json @@ -0,0 +1,25 @@ +{ + "details": { + "parent_model": "", + "format": "gguf", + "family": "gemma4", + "families": [ + "gemma4" + ], + "parameter_size": "4.65B", + "quantization_level": "Q4_K_M" + }, + "model_info": { + "gemma4.context_length": 131072, + "gemma4.block_count": 35, + "general.architecture": "gemma4", + "qwen35.context_length": 262144 + }, + "capabilities": [ + "tools", + "completion", + "vision", + "audio" + ], + "parameters": "stop \"\"\nstop \"<|turn>\"\nstop \"\"\nstop \"<|turn>user\"" +} \ No newline at end of file diff --git a/test/ollama-model-info.test.ts b/test/ollama-model-info.test.ts new file mode 100644 index 0000000..bc656fb --- /dev/null +++ b/test/ollama-model-info.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { readFileSync } from 'node:fs' +import { createOllamaEnricher } from '../src/utils/model-info/ollama' +import { resolveModelInfoEnricher } from '../src/core/enricher-resolver' +import { discoverModelDrafts } from '../src/core/discovery-pipeline' +import { mapToV1Model } from '../src/core/model-mapper' +import { ModelInfoFormat } from '../src/types/plugin-config' + +const showGemmaBody: unknown = JSON.parse( + readFileSync(new URL('./fixtures/ollama/show-gemma.json', import.meta.url), 'utf8'), +) + +describe('native Ollama enricher', () => { + it('parses family-keyed model_info context length from a /api/show body', () => { + const result = createOllamaEnricher(new Map([['gemma4', showGemmaBody]])) + .enrich({ id: 'gemma4' }, { filterNonChat: true }) + + expect(result.limit).toEqual({ context: 131072, output: 32000 }) + }) + + it('prefers the details.family-prefixed key among multiple .context_length keys', () => { + const result = createOllamaEnricher(new Map([['gemma4', showGemmaBody]])) + .enrich({ id: 'gemma4' }, { filterNonChat: true }) + + expect(result.limit?.context).toBe(131072) + }) + + it('falls back to top-level context_length when model_info has no usable .context_length key', () => { + const result = createOllamaEnricher(new Map([['m', { model_info: {}, context_length: 8192 }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.limit?.context).toBe(8192) + }) + + it('resolves num_ctx from the parameters string when no context_length source exists', () => { + const result = createOllamaEnricher(new Map([['m', { parameters: 'num_ctx 4096 num_predict -1 stop x' }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.limit).toEqual({ context: 4096, output: 4096 }) + }) + + it('accepts num_ctx line variants in the parameters string', () => { + const unquoted = createOllamaEnricher(new Map([['m', { parameters: 'num_ctx: 8192' }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + const quoted = createOllamaEnricher(new Map([['m', { parameters: '"num_ctx": 16384' }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(unquoted.limit?.context).toBe(8192) + expect(quoted.limit?.context).toBe(16384) + }) + + it('prefers a valid num_ctx over model_info and top-level context_length sources', () => { + const body = { + details: { family: 'gemma4' }, + model_info: { 'gemma4.context_length': 131072, 'qwen35.context_length': 262144 }, + context_length: 512, + parameters: 'num_ctx 64000', + } + const result = createOllamaEnricher(new Map([['gemma4', body]])) + .enrich({ id: 'gemma4' }, { filterNonChat: true }) + + expect(result.limit?.context).toBe(64000) + }) + + it('falls back to the family-keyed context_length when num_ctx is not usable', () => { + const result = createOllamaEnricher(new Map([['m', { + details: { family: 'gemma4' }, + model_info: { 'gemma4.context_length': 131072 }, + parameters: 'num_ctx 0', + }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.limit?.context).toBe(131072) + }) + + it('sets no limit when all context sources are missing', () => { + const body = { + ...(showGemmaBody as Record), + model_info: { 'gemma4.block_count': 35 }, + parameters: 'stop x', + } + const result = createOllamaEnricher(new Map([['gemma4', body]])) + .enrich({ id: 'gemma4' }, { filterNonChat: true }) + + expect(result.limit).toBeUndefined() + }) + + it('ignores non-positive and non-numeric context sources', () => { + const result = createOllamaEnricher(new Map([['m', { + model_info: { 'gemma4.context_length': 0 }, + context_length: -1, + parameters: 'stop x', + }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.limit).toBeUndefined() + }) + + it('rejects degenerate num_ctx values in the parameters string', () => { + const zero = createOllamaEnricher(new Map([['m', { parameters: 'num_ctx 0' }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + const negative = createOllamaEnricher(new Map([['m', { parameters: 'num_ctx -4' }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(zero.limit).toBeUndefined() + expect(negative.limit).toBeUndefined() + }) + + it('rescans other family keys when the details.family key is unusable', () => { + const result = createOllamaEnricher(new Map([['m', { + details: { family: 'gemma4' }, + model_info: { 'gemma4.context_length': 0, 'qwen35.context_length': 8192 }, + }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.limit?.context).toBe(8192) + }) + + it('maps capabilities to modalities and toolCall from the fixture', () => { + const result = createOllamaEnricher(new Map([['gemma4', showGemmaBody]])) + .enrich({ id: 'gemma4' }, { filterNonChat: true }) + + expect(result.modalities).toEqual({ input: ['text', 'image', 'audio'], output: ['text'] }) + expect(result.toolCall).toBe(true) + expect(result.reasoning).toBeUndefined() + }) + + it('maps the thinking capability to reasoning', () => { + const result = createOllamaEnricher(new Map([['m', { capabilities: ['thinking'] }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.reasoning).toBe(true) + }) + + it('accepts the images alias as a vision marker', () => { + const result = createOllamaEnricher(new Map([['m', { capabilities: ['images'] }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result.modalities?.input).toContain('image') + }) + + it('accepts function_calling and tool_calling as tool markers', () => { + const functionCalling = createOllamaEnricher(new Map([['m', { capabilities: ['function_calling'] }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + const toolCalling = createOllamaEnricher(new Map([['m', { capabilities: ['tool_calling'] }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(functionCalling.toolCall).toBe(true) + expect(toolCalling.toolCall).toBe(true) + }) + + it('ignores unknown capabilities and invents no fields', () => { + const result = createOllamaEnricher(new Map([['m', { capabilities: ['completion', 'quantized'] }]])) + .enrich({ id: 'm' }, { filterNonChat: true }) + + expect(result).toEqual({}) + }) + + it('does not enrich unknown models', () => { + expect(createOllamaEnricher(new Map([['gemma4', showGemmaBody]])) + .enrich({ id: 'qwen3' }, { filterNonChat: true })).toEqual({}) + }) + + it('does not enrich non-object or empty show bodies', () => { + const enricher = createOllamaEnricher(new Map([ + ['broken', 'not-an-object'], + ['null', null], + ])) + + expect(enricher.enrich({ id: 'broken' }, { filterNonChat: true })).toEqual({}) + expect(enricher.enrich({ id: 'null' }, { filterNonChat: true })).toEqual({}) + }) +}) + +describe('V1 enrichment flow', () => { + it('consumes the resolver-produced Ollama enricher and populates V1 limit and tool_call', async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => showGemmaBody }) + + const enricher = await resolveModelInfoEnricher( + { baseURL: 'http://127.0.0.1:11434/v1', fetcher }, + { format: ModelInfoFormat.Ollama, modelIds: ['gemma4:latest'] }, + ) + + const drafts = discoverModelDrafts( + [{ id: 'gemma4:latest', name: 'gemma4:latest', raw: { id: 'gemma4:latest' } }], + { + filter: { includeBy: [], excludeBy: [], includeRegex: [], excludeRegex: [] }, + smartModelName: false, + enricher, + enrichmentContext: { filterNonChat: true }, + }, + ) + + expect(drafts).toHaveLength(1) + const model = mapToV1Model(drafts[0]!) + expect(model.limit?.context).toBe(131072) + expect(model.tool_call).toBe(true) + expect(model.reasoning).toBeUndefined() + }) +}) diff --git a/test/v2/enrichment.test.ts b/test/v2/enrichment.test.ts index 539a961..c510186 100644 --- a/test/v2/enrichment.test.ts +++ b/test/v2/enrichment.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest" +import { readFileSync } from "node:fs" import { discoverInventory } from "../../src/v2/discovery.js" import { parseProviderDiscoveryOptions } from "../../src/v2/provider-config.js" import { ModelInfoFormat } from "../../src/types/plugin-config.js" @@ -210,4 +211,79 @@ describe("V2 provider discovery with metadata enrichment", () => { modelsDevTestUtils.resetCache() } }) + + it("sequentializes the Ollama model fetch before per-model /api/show enrichment", async () => { + const showGemmaBody: unknown = JSON.parse( + readFileSync(new URL("../fixtures/ollama/show-gemma.json", import.meta.url), "utf8"), + ) + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + smartModelName: true, + modelInfoFormat: ModelInfoFormat.Ollama, + })!], + ]) + + const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes("/api/show")) { + const body = JSON.parse(String(init?.body)) as { model: string } + if (body.model === "gemma4:latest") { + return { ok: true, json: async () => showGemmaBody } as Response + } + return { + ok: true, + json: async () => ({ model_info: { "mistral3.context_length": 8192 }, capabilities: ["tools"] }), + } as Response + } + return { + ok: true, + json: async () => ({ data: [{ id: "gemma4:latest" }, { id: "mistral:latest" }] }), + } as Response + }) + + const inventory = await discoverInventory([{ + id: "local", + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { baseURL: "http://127.0.0.1:11434/v1" }, + }], options, fetcher as unknown as typeof fetch) + + const models = inventory.get("local")! + expect(models.get("gemma4:latest")?.limit.context).toBe(131_072) + expect(models.get("mistral:latest")?.limit.context).toBe(8_192) + expect(models.get("gemma4:latest")?.capabilities.tools).toBe(true) + expect(models.get("gemma4:latest")?.capabilities.input).toContain("image") + expect(models.get("gemma4:latest")?.capabilities.input).toContain("audio") + expect(fetcher.mock.calls.filter(([url]) => String(url).includes("/api/show"))).toHaveLength(2) + }) + + it("degrades to no enrichment when every Ollama /api/show request fails", async () => { + const options = new Map([ + ["local", parseProviderDiscoveryOptions({ + enabled: true, + smartModelName: true, + modelInfoFormat: ModelInfoFormat.Ollama, + })!], + ]) + + const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).includes("/api/show")) { + return { ok: false, status: 404 } as Response + } + void init + return { + ok: true, + json: async () => ({ data: [{ id: "gemma4:latest" }, { id: "mistral:latest" }] }), + } as Response + }) + + const inventory = await discoverInventory([{ + id: "local", + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { baseURL: "http://127.0.0.1:11434/v1" }, + }], options, fetcher as unknown as typeof fetch) + + const models = inventory.get("local")! + expect(models.size).toBe(2) + expect(models.get("gemma4:latest")?.limit).toEqual({ context: 200_000, output: 32_000 }) + }) })