From 33057d02a47e84cf789aa62b8b2cbdf621df0f87 Mon Sep 17 00:00:00 2001 From: Pranjal Agarwal Date: Fri, 16 Oct 2026 16:49:39 +0530 Subject: [PATCH 01/10] fix(litellm): derive video input modality from supports_video_input --- src/types/index.ts | 1 + src/utils/model-info/litellm.ts | 13 ++++++++++--- test/litellm-model-info.test.ts | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 06257237..0faa1d9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,6 +27,7 @@ export interface LiteLLMModelInfo { supports_max_reasoning_effort?: boolean | null supported_openai_params?: string[] | null supports_vision?: boolean | null + supports_video_input?: boolean | null modalities?: { input?: string[] | null output?: string[] | null diff --git a/src/utils/model-info/litellm.ts b/src/utils/model-info/litellm.ts index 86862ab4..9a0e49c8 100644 --- a/src/utils/model-info/litellm.ts +++ b/src/utils/model-info/litellm.ts @@ -95,9 +95,16 @@ function buildModalities(info: LiteLLMModelInfo): { input: string[]; output: str if (input || output) { return { input: input ?? ['text'], output: output ?? ['text'] } } - // Older LiteLLM deployments do not expose modalities; derive image input from supports_vision. - if (info.supports_vision === true) { - return { input: ['text', 'image'], output: ['text'] } + // Older LiteLLM deployments do not expose modalities; derive inputs from the capability flags. + if (info.supports_vision === true || info.supports_video_input === true) { + return { + input: [ + 'text', + ...(info.supports_vision === true ? ['image'] : []), + ...(info.supports_video_input === true ? ['video'] : []), + ], + output: ['text'], + } } return undefined } diff --git a/test/litellm-model-info.test.ts b/test/litellm-model-info.test.ts index c8e7eee0..bbc00190 100644 --- a/test/litellm-model-info.test.ts +++ b/test/litellm-model-info.test.ts @@ -16,6 +16,20 @@ describe('native LiteLLM enricher', () => { expect(enrich({ supports_vision: true }).modalities).toEqual({ input: ['text', 'image'], output: ['text'] }) }) + it('derives video input from supports_video_input', () => { + expect(enrich({ supports_video_input: true }).modalities).toEqual({ input: ['text', 'video'], output: ['text'] }) + }) + + it('combines vision and video capability flags', () => { + expect(enrich({ supports_vision: true, supports_video_input: true }).modalities) + .toEqual({ input: ['text', 'image', 'video'], output: ['text'] }) + }) + + it('prefers declared modalities over capability flags', () => { + expect(enrich({ modalities: { input: ['text'] }, supports_vision: true, supports_video_input: true }).modalities) + .toEqual({ input: ['text'], output: ['text'] }) + }) + it('defaults a missing modality side to text', () => { expect(enrich({ modalities: { input: ['text', 'image'] } }).modalities) .toEqual({ input: ['text', 'image'], output: ['text'] }) From cccccced1d9bcb2bae2c2375659f1d744fd78e2f Mon Sep 17 00:00:00 2001 From: yuhp Date: Sat, 17 Oct 2026 22:49:26 +0800 Subject: [PATCH 02/10] feat(v2): add descriptive discovery error logs for troubleshooting (refs #156) - Add DiscoveryLogger interface and default console logger fallback - Log warning with target URL, HTTP status, and statusText when models request fails - Log warning with payload type and object keys when data property is not an array - Log network and parsing exceptions in catch handler - Pass logger to resolveModelInfoEnricher to surface model info failures - Add unit tests for non-ok status, invalid payload schema, and network errors --- src/v2/discovery.ts | 66 +++++++++++++++++++++++++++++++++--- test/v2/discovery.test.ts | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/v2/discovery.ts b/src/v2/discovery.ts index b16b4482..6c5d7e6d 100644 --- a/src/v2/discovery.ts +++ b/src/v2/discovery.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { type ConfiguredProvider, type DiscoveredV2Model, type Inventory } from "./catalog.js" import { type ProviderDiscoveryOptions } from "./provider-config.js" import { mapToDiscoveredV2Model } from "../core/model-mapper.js" @@ -6,6 +7,30 @@ import type { ModelEnrichmentResult, ModelEnricher } from "../core/model-enrichm import { createDiscoveryCacheEntry, discoveryCacheKey, readDiscoveryCache, writeDiscoveryCache, type DiscoveryCacheBackend, type DiscoveryCacheEntry } from "../core/discovery-cache.js" import { resolveModelInfoEnricher } from "../core/enricher-resolver.js" +export interface DiscoveryLogger { + warn(message: string, context?: Record): void + info?(message: string, context?: Record): void + debug?(message: string, context?: Record): void + error?(message: string, context?: Record): void +} + +const defaultLogger: DiscoveryLogger = { + warn(message, context) { + if (context && Object.keys(context).length > 0) { + console.warn(`[opencode-models-discovery] ${message}`, context) + } else { + console.warn(`[opencode-models-discovery] ${message}`) + } + }, + info(message, context) { + if (context && Object.keys(context).length > 0) { + console.info(`[opencode-models-discovery] ${message}`, context) + } else { + console.info(`[opencode-models-discovery] ${message}`) + } + }, +} + export interface CatalogProvider extends ConfiguredProvider { /** Ephemeral request credential resolved by the plugin refresh orchestration. */ readonly apiKey?: string @@ -24,16 +49,22 @@ export async function discoverInventory( discovery: ReadonlyMap, fetcher: typeof fetch = fetch, storage?: DiscoveryCacheBackend, - options?: { readonly force?: boolean }, + options?: { readonly force?: boolean; readonly logger?: DiscoveryLogger }, ): Promise { const inventory: Inventory = new Map() + const logger = options?.logger ?? defaultLogger await Promise.all(providers.map(async (provider) => { const config = discovery.get(provider.id) if (!config) return const baseURL = typeof provider.settings.baseURL === "string" ? provider.settings.baseURL : undefined - if (!baseURL) return + if (!baseURL) { + logger.warn(`Model discovery skipped for provider "${provider.id}": missing or invalid settings.baseURL`, { + provider: provider.id, + }) + return + } const cacheConfig = config.cache const cacheSupported = cacheConfig?.enabled === true && storage !== undefined @@ -75,6 +106,10 @@ export async function discoverInventory( 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, @@ -83,10 +118,26 @@ export async function discoverInventory( }), ]) - if (!modelsResponse.ok) return + 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 } - if (!Array.isArray(payload?.data)) return + if (!Array.isArray(payload?.data)) { + 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 = payload.data enricher = resolvedEnricher } @@ -124,7 +175,12 @@ export async function discoverInventory( !!model && typeof model === "object" && !Array.isArray(model) && typeof (model as Record).id === "string" ), enrichments)) } - } catch { + } catch (error) { + logger.warn(`Model discovery error for provider "${provider.id}"`, { + provider: provider.id, + url, + error: error instanceof Error ? error.message : String(error), + }) // Network and parsing failures are non-fatal; existing discovered models remain untouched. } })) diff --git a/test/v2/discovery.test.ts b/test/v2/discovery.test.ts index be6edfa1..e505a3da 100644 --- a/test/v2/discovery.test.ts +++ b/test/v2/discovery.test.ts @@ -156,4 +156,74 @@ describe("V2 provider discovery", () => { expect(inventory.has("unavailable")).toBe(false) expect(inventory.get("local")?.get("qwen-available")?.modelID).toBe("qwen-available") }) + + it("logs helpful warning when models request fails with non-ok status", async () => { + const logger = { warn: vi.fn() } + const fetcher = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }) + + const inventory = await discoverInventory([ + { id: "my-provider", package: "@opencode-ai/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/api" } }, + ], new Map([ + ["my-provider", parseProviderDiscoveryOptions({ enabled: true, endpoint: "/models" })!], + ]), fetcher, undefined, { logger }) + + expect(inventory.has("my-provider")).toBe(false) + expect(logger.warn).toHaveBeenCalledWith( + 'Model discovery request failed for provider "my-provider"', + expect.objectContaining({ + provider: "my-provider", + url: "https://example.com/models", + status: 404, + statusText: "Not Found", + }), + ) + }) + + it("logs warning when models endpoint returns non-array data payload", async () => { + const logger = { warn: vi.fn() } + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ error: "Endpoint not supported" }), + }) + + const inventory = await discoverInventory([ + { id: "my-provider", package: "@opencode-ai/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/api" } }, + ], new Map([ + ["my-provider", parseProviderDiscoveryOptions({ enabled: true, endpoint: "/models" })!], + ]), fetcher, undefined, { logger }) + + expect(inventory.has("my-provider")).toBe(false) + expect(logger.warn).toHaveBeenCalledWith( + 'Model discovery returned invalid payload for provider "my-provider": expected array in "data" property', + expect.objectContaining({ + provider: "my-provider", + url: "https://example.com/models", + }), + ) + }) + + it("logs warning when models endpoint throws network error", async () => { + const logger = { warn: vi.fn() } + const fetcher = vi.fn().mockRejectedValue(new Error("fetch failed: connection refused")) + + const inventory = await discoverInventory([ + { id: "my-provider", package: "@opencode-ai/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/api" } }, + ], new Map([ + ["my-provider", parseProviderDiscoveryOptions({ enabled: true })!], + ]), fetcher, undefined, { logger }) + + expect(inventory.has("my-provider")).toBe(false) + expect(logger.warn).toHaveBeenCalledWith( + 'Model discovery error for provider "my-provider"', + expect.objectContaining({ + provider: "my-provider", + url: "https://example.com/v1/models", + error: "fetch failed: connection refused", + }), + ) + }) }) From 53e68315a64169c438078024346fe681128c5aca Mon Sep 17 00:00:00 2001 From: yuhp Date: Sat, 17 Oct 2026 22:55:33 +0800 Subject: [PATCH 03/10] docs: clarify @rc dist-tag usage during pre-release testing (refs #157) - Update OpenCode v2 configuration examples to use opencode-models-discovery@rc - Add pre-release notes explaining RC testing versus final v2.0.0 release - Update migration guide and configuration documentation --- README.md | 6 ++++-- docs/configuration.md | 4 +++- docs/migrate-v1-to-v2.md | 4 ++-- docs/releases/v2.0.0.md | 2 ++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ad2adce7..975ac08e 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,15 @@ Originally inspired by [opencode-lmstudio](https://github.com/agustif/opencode-l OpenCode v2 uses **`plugins`** (an array of package names) and **`providers`** (a map). Configure discovery under `providers..settings.modelsDiscovery`; it runs only when `enabled` is explicitly `true`. OpenCode v1 instead uses `plugin`, `provider`, and `options` (see [OpenCode v1 quick start](#opencode-v1-quick-start)). Add the plugin package to your OpenCode v2 `opencode.json`. OpenCode resolves -and loads the package automatically: +and loads the package automatically. + +> **Note on Pre-releases**: During Release Candidate testing, use `"opencode-models-discovery@rc"` (or pin to a specific release like `"opencode-models-discovery@2.0.0-rc.2"`). Once `v2.0.0` is published to `latest`, `"opencode-models-discovery@2.0.0"` and `"opencode-models-discovery@latest"` will resolve directly. ```json { "$schema": "https://opencode.ai/config.json", "plugins": [ - "opencode-models-discovery@2.0.0" + "opencode-models-discovery@rc" ], "providers": { "gateway-custom": { diff --git a/docs/configuration.md b/docs/configuration.md index 6fc23a31..21acecd4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,11 +10,13 @@ For a step-by-step conversion between the two configuration formats, see [Migrat OpenCode v2 uses `plugins` and `providers`. The plugin can be declared directly as a package name in `plugins`. Discovery configuration is read from `providers..settings.modelsDiscovery`; it is not read from `provider..options.modelsDiscovery` or `plugins[].options.providers`. +> **Note on Pre-releases**: During Release Candidate testing, configure `"opencode-models-discovery@rc"` (or pin to `"opencode-models-discovery@2.0.0-rc.2"`). Once `v2.0.0` is published to `latest`, `"opencode-models-discovery@2.0.0"` or `"opencode-models-discovery@latest"` will resolve directly. + ```json { "$schema": "https://opencode.ai/config.json", "plugins": [ - "opencode-models-discovery@2.0.0" + "opencode-models-discovery@rc" ], "providers": { "lmstudio": { diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index 40ae6014..a2ea91ad 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@rc"]` (or `@2.0.0` once released) | | `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@rc" ], "providers": { "gateway": { diff --git a/docs/releases/v2.0.0.md b/docs/releases/v2.0.0.md index 26f64f14..927db39c 100644 --- a/docs/releases/v2.0.0.md +++ b/docs/releases/v2.0.0.md @@ -43,6 +43,8 @@ providers..settings.modelsDiscovery Example: +> **Pre-release Note**: During RC testing, install using `"opencode-models-discovery@rc"`. + ```json { "plugins": ["opencode-models-discovery@2.0.0"], From 2ea0e0b29e516826b58890b444b5d5cdc6a1f701 Mon Sep 17 00:00:00 2001 From: yuhp Date: Sun, 18 Oct 2026 09:55:06 +0800 Subject: [PATCH 04/10] docs: clarify v2 model variant configuration --- docs/configuration.md | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 21acecd4..961953b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -178,16 +178,48 @@ See [Persisted Model Discovery Cache](persisted-model-discovery.md) for the comp ## Model Assembly And Customization -For each eligible provider, the plugin produces its final `provider..models` map in this order: +For each eligible provider, the plugin produces its final model map in this order. The configuration path is `provider..models` for the V1 legacy adapter and `providers..models` for OpenCode v2: 1. Obtain the discovered model set from a fresh provider request or a valid persisted cache entry. 2. For a live request, apply model filters, build OpenCode model configuration, and apply the optional `modelInfoFormat` enrichment. 3. Apply saved plugin-managed per-model overrides when present. -4. Apply matching explicit `provider..models.` configuration from `opencode.json`. +4. Apply matching explicit `provider..models.` (V1) or `providers..models.` (V2) configuration from `opencode.json`. 5. Preserve explicit model entries whose ids were not discovered as standalone models. For a matching model id, the discovered or cached configuration is the base and explicit configuration is a recursive override. Nested objects merge, while arrays and scalar values replace the existing value. The discovered model id remains authoritative, so an explicit `id` value cannot rename it. +### OpenCode v2 + +OpenCode v2 uses `providers` and represents model variants as an array of entries with `id` and `settings` fields: + +```json +{ + "providers": { + "gateway": { + "models": { + "example-model": { + "options": { + "customRouting": true + }, + "variants": [ + { "id": "none", "settings": { "reasoningEffort": "none" } }, + { "id": "low", "settings": { "reasoningEffort": "low" } }, + { "id": "high", "settings": { "reasoningEffort": "high" } } + ] + }, + "manual-only-model": { + "name": "Manual Only Model" + } + } + } + } +} +``` + +### OpenCode v1 legacy adapter + +The following uses the V1 configuration shape. V1 uses `provider` and the legacy object form for `variants`: + ```json { "provider": { @@ -212,11 +244,11 @@ For a matching model id, the discovered or cached configuration is the base and } ``` -If `example-model` was discovered with a name, limits, modalities, and capability metadata, those fields remain available and `options.customRouting` plus `variants.high` are added. `manual-only-model` remains available even if the provider does not return it from its discovery endpoint. +If `example-model` was discovered with a name, limits, modalities, and capability metadata, those fields remain available and the explicit customization is merged on top. `manual-only-model` remains available even if the provider does not return it from its discovery endpoint. -OpenCode validates `provider..models.` against its own model schema before plugins run. Use documented model fields such as `options` or `variants` for custom values; arbitrary top-level keys are removed by OpenCode and cannot be merged by this plugin. +OpenCode validates each model against its own model schema before plugins run. Use documented model fields such as `options` or `variants` for custom values; arbitrary top-level keys are removed by OpenCode and cannot be merged by this plugin. For V2, use the array form shown above; the object-shaped `variants` example is only for the V1 legacy adapter. -The persisted cache stores only the filtered and enriched discovered base models. It does not store explicit `provider..models` configuration, so explicit customizations are applied from the active OpenCode config on every startup. This keeps provider inventory data separate from user configuration. +The persisted cache stores only the filtered and enriched discovered base models. It does not store explicit `provider..models` (V1) or `providers..models` (V2) configuration, so explicit customizations are applied from the active OpenCode config on every startup. This keeps provider inventory data separate from user configuration. ## Default Enablement From 9ddb5b42a90a7c68b59226c21a80a6c78f3c2187 Mon Sep 17 00:00:00 2001 From: yuhp Date: Sun, 18 Oct 2026 20:48:23 +0800 Subject: [PATCH 05/10] docs: document opencode v2 in-memory settings merge on reload --- docs/configuration.md | 16 ++++ .../opencode-v2-settings-merge-on-reload.md | 87 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 docs/issues/opencode-v2-settings-merge-on-reload.md diff --git a/docs/configuration.md b/docs/configuration.md index 961953b1..6d6d646b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,6 +57,22 @@ Optionally, you can set `providers..settings.integrationID` if you want a pr The V2 adapter does not use the V1 persisted disk-cache format or V1 XDG auth-store fallback. V2 caching is disabled by default. When `modelsDiscovery.cache.enabled` is `true`, V2 stores the raw model-list response and enrichment results through the host-provided `ctx.storage` API. `ttlSeconds` defaults to `86400` seconds (24 hours). The `models_discovery_status` tool accepts `rawCache: true` to include cached raw models and enrichments in its report. The V2 adapter also provides the native TUI commands `/models-discovery-refresh` and `/models-discovery-console`, plus the `models_discovery_refresh` and `models_discovery_status` agent tools. `/models-discovery-console` opens a unified console for browsing Providers → models → structured model metadata and for inspecting or clearing caches. Use `/models-discovery-console status --provider hyy` to filter the browser to one Provider. Per-model overrides use the `overrides::` storage namespace and are independent from discovery cache clearing. After rebuilding a local plugin, restart the OpenCode v2 background service with `opencode service restart`. +### Hot-reloading and Deleting Settings in OpenCode v2 + +OpenCode v2 watches `opencode.json` and emits `config.updated` lifecycle events when the file changes. The plugin subscribes to these events and automatically refreshes provider configurations and discovered models. + +> **Important (Host In-Memory Merge Behavior)**: +> In current OpenCode v2 versions, the host merges updated provider `settings` into its in-memory provider state rather than replacing the settings object: +> - **Modifying setting values** (e.g. changing `"smartModelName": true` to `"smartModelName": false`): The host overwrites the setting in memory with the new value, and discovery refreshes immediately with the new setting. +> - **Deleting setting keys** (e.g. removing `"smartModelName"` entirely): Because the key is absent in the updated file, the host's in-memory merge does not remove the existing key. `ctx.provider.list()` continues to return the previously loaded setting. +> +> **Best Practices**: +> 1. To disable a previously configured feature (such as `smartModelName` or `cache.enabled`), explicitly configure `"smartModelName": false` instead of deleting the property. +> 2. If you delete settings keys and want in-memory provider configurations to reset to default values, restart the OpenCode background service: +> ```bash +> opencode service restart +> ``` + ## OpenCode v1 configuration The remainder of this guide documents the OpenCode v1 adapter. Use `provider..options.modelsDiscovery` for provider-specific behavior. This is the supported provider-level configuration boundary for OpenCode v1. diff --git a/docs/issues/opencode-v2-settings-merge-on-reload.md b/docs/issues/opencode-v2-settings-merge-on-reload.md new file mode 100644 index 00000000..0b061a2e --- /dev/null +++ b/docs/issues/opencode-v2-settings-merge-on-reload.md @@ -0,0 +1,87 @@ +# OpenCode v2 Issue: Provider Settings Merged Rather Than Replaced on Configuration Reload + +## Summary + +When `opencode.json` is modified while the OpenCode background service is running, OpenCode emits a `config.updated` lifecycle event and updates its in-memory provider registry. However, the host performs an object merge (`deepMerge`) on `providers..settings` instead of replacing the settings object with the new file contents. + +As a result: +- **Modifying an existing key** (e.g., from `true` to `false`) correctly overwrites the in-memory value. +- **Deleting an existing key** from `opencode.json` leaves the previous in-memory value intact because the key is absent in the new file and is not deleted by the merge operation. +- Calling `ctx.provider.list()` or `GET /api/provider` continues to return the deleted key with its old value. + +## Environment + +- OpenCode version: `2.0.30` +- `@opencode/plugin`: `2.0.26` +- OS: macOS (Darwin arm64) + +## Steps to Reproduce + +1. Configure a provider in `opencode.json` with an explicit setting: + ```json + { + "providers": { + "my-provider": { + "name": "My Provider", + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { + "baseURL": "http://127.0.0.1:8000/v1", + "modelsDiscovery": { + "enabled": true, + "smartModelName": true + } + } + } + } + } + ``` +2. Start or observe the running OpenCode service. Querying `ctx.provider.list()` shows: + ```json + "settings": { + "modelsDiscovery": { + "enabled": true, + "smartModelName": true + } + } + ``` +3. Edit `opencode.json` to **delete** `"smartModelName": true`: + ```json + { + "providers": { + "my-provider": { + "name": "My Provider", + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { + "baseURL": "http://127.0.0.1:8000/v1", + "modelsDiscovery": { + "enabled": true + } + } + } + } + } + ``` +4. Observe the `config.updated` event fired by OpenCode. +5. In the event listener or via `ctx.provider.list()`, inspect `my-provider.settings.modelsDiscovery`. + +## Observed Behavior + +`ctx.provider.list()` still contains: +```json +"modelsDiscovery": { + "enabled": true, + "smartModelName": true +} +``` +The deleted property `"smartModelName"` is preserved in memory. + +If step 3 is changed to explicitly set `"smartModelName": false`, `ctx.provider.list()` immediately reflects `"smartModelName": false`. + +## Expected Behavior + +When `opencode.json` changes, the provider's `settings` object should reflect the exact parsed contents of the updated configuration file (or missing keys should be deleted/reset), so that deleting a setting reverts it to its default behavior. + +## Workaround + +- Users should explicitly set keys to `false` or their default values (e.g., `"smartModelName": false`) rather than deleting them from `opencode.json`. +- Alternatively, restarting the OpenCode service (`opencode service restart`) forces a clean load from disk. From 7fbc9afe80a9b9a5e3ce24e5b97e2577ff8007f2 Mon Sep 17 00:00:00 2001 From: yuhp Date: Sun, 18 Oct 2026 19:23:20 +0800 Subject: [PATCH 06/10] fix(v2): avoid empty catalog during discovery refresh --- src/v2/index.ts | 12 ++++++++---- test/v2/plugin.test.ts | 41 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index 9ab581d0..a775cd53 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -169,16 +169,20 @@ export async function setupV2(ctx: Plugin.Context): Promise<() => void> { let activeRefresh: Promise | undefined let pendingRefreshForce = false - const refreshFromCurrentConfig = (force?: boolean): Promise => { + const refreshFromCurrentConfig = (force?: boolean, reloadProviderConfig = false): Promise => { if (force) pendingRefreshForce = true if (activeRefresh) { - return activeRefresh.then(() => refreshFromCurrentConfig(pendingRefreshForce)) + return activeRefresh.then(() => refreshFromCurrentConfig(pendingRefreshForce, reloadProviderConfig)) } const runForce = force || pendingRefreshForce pendingRefreshForce = false activeRefresh = (async () => { try { - await ctx.provider.reload() + // Configuration events update the host provider registry asynchronously, so + // reload it before listing providers. A normal discovery refresh must not do + // this first: the host can rebuild its catalog with an empty inventory while + // the new models request is still in flight, causing a transient 404. + if (reloadProviderConfig) await ctx.provider.reload() await syncConfiguredProviders() return await refreshInventoryInternal(runForce) } finally { @@ -447,7 +451,7 @@ export async function setupV2(ctx: Plugin.Context): Promise<() => void> { // Configuration and credential updates are delivered independently from the provider // registry. Reload the registry first so provider.list() observes the // new state before rebuilding the discovery inventory. - await refreshFromCurrentConfig() + await refreshFromCurrentConfig(undefined, true) } catch { if (!abort.signal.aborted) { // should not happen, but if it does, we don't want to crash the plugin diff --git a/test/v2/plugin.test.ts b/test/v2/plugin.test.ts index 6fd073bb..2095407f 100644 --- a/test/v2/plugin.test.ts +++ b/test/v2/plugin.test.ts @@ -71,7 +71,7 @@ describe("V2 plugin entrypoint", () => { expect(ctx.integration.transform).toHaveBeenCalledTimes(1) expect(ctx.provider.transform).toHaveBeenCalledTimes(1) expect(ctx.integration.reload).toHaveBeenCalledTimes(1) - expect(ctx.provider.reload).toHaveBeenCalledTimes(2) + expect(ctx.provider.reload).toHaveBeenCalledTimes(1) expect(fetcher).toHaveBeenCalledTimes(1) } finally { fetcher.mockRestore() @@ -197,7 +197,7 @@ describe("V2 plugin entrypoint", () => { transform: vi.fn().mockImplementation(async (callback) => callback({ get: vi.fn().mockReturnValue(undefined), add: vi.fn(), update: vi.fn(), models: { set: vi.fn() } })), reload: vi.fn().mockImplementation(async () => { providerReloads += 1 - if (providerReloads >= 3) { + if (providerReloads >= 1) { currentBaseURL = "http://127.0.0.1:5678/v1" } }), @@ -216,7 +216,7 @@ describe("V2 plugin entrypoint", () => { try { await plugin.setup(ctx as never) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(ctx.provider.reload).toHaveBeenCalledTimes(4) + expect(ctx.provider.reload).toHaveBeenCalledTimes(3) expect(ctx.integration.transform).toHaveBeenCalledTimes(1) expect(ctx.provider.transform).toHaveBeenCalledTimes(1) expect(fetcher).toHaveBeenNthCalledWith(1, "http://127.0.0.1:1234/v1/models", expect.any(Object)) @@ -316,6 +316,41 @@ describe("V2 plugin entrypoint", () => { } }) + it("does not reload the provider catalog until a refresh has discovered models", async () => { + let releaseRefresh: (() => void) | undefined + const refreshStarted = new Promise((resolve) => { releaseRefresh = resolve }) + const fetcher = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: "initial-model" }] }) } as Response) + .mockImplementationOnce(async () => { + await refreshStarted + return { ok: true, json: async () => ({ data: [{ id: "refreshed-model" }] }) } as Response + }) + const rpcHandlers: Record Promise> = {} + const ctx = context({ + rpc: { + register: vi.fn().mockImplementation(async (definition, handlers) => { + if (definition.id === "opencode.models-discovery") Object.assign(rpcHandlers, handlers) + }), + }, + }) + + try { + await plugin.setup(ctx as never) + expect(ctx.provider.reload).toHaveBeenCalledTimes(1) + + const refresh = rpcHandlers.refresh as (input?: unknown) => Promise + const pending = refresh({ force: true }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(ctx.provider.reload).toHaveBeenCalledTimes(1) + + releaseRefresh?.() + await pending + expect(ctx.provider.reload).toHaveBeenCalledTimes(2) + } finally { + fetcher.mockRestore() + } + }) + it("keeps discovered defaults separate from overridden values after a failed refresh", async () => { const fetcher = vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce({ ok: true, json: async () => ({ data: [{ id: "stable-model" }] }) } as Response) From 73308023113c8d4eb059e66655bb4526e2696860 Mon Sep 17 00:00:00 2001 From: Ami-OS Date: Sun, 18 Oct 2026 20:58:00 +0800 Subject: [PATCH 07/10] feat: add OpenRouter model metadata format --- README.md | 2 +- docs/configuration.md | 33 ++++++- docs/v1-v2-shared-core-refactor-prd.md | 2 +- src/core/enricher-resolver.ts | 3 +- src/plugin/commands.ts | 2 + src/types/plugin-config.ts | 1 + src/utils/model-info/index.ts | 2 + src/utils/model-info/openrouter.ts | 117 +++++++++++++++++++++++++ test/core/enricher-resolver.test.ts | 11 +++ test/core/model-parity.test.ts | 33 +++++++ test/openrouter-model-info.test.ts | 64 ++++++++++++++ test/plugin.test.ts | 1 + 12 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 src/utils/model-info/openrouter.ts create mode 100644 test/openrouter-model-info.test.ts diff --git a/README.md b/README.md index 975ac08e..93fc79d5 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ events without requiring a restart. See [Connect and Auth Documentation](docs/connect-and-auth.md) for credential resolution, lifecycle handling, and security details. -`modelInfoFormat` and `smartModelName` are optional: without them, discovered names remain the model IDs and no external metadata is fetched. Supported metadata formats are `aiproxy`, `models.dev`, `bifrost`, `litellm`, `vllm`, `lmstudio`, `llama-swap`, and `omniroute`. `models.dev` fetches `https://models.dev/models.json` by default; `modelInfoEndpoint` can override the catalog URL. The `aiproxy` format composes that catalog with inline metadata from AIProxy's model list. `filterNonChat` defaults to `true`; it can filter non-chat models when the selected metadata source supplies that information. `timeoutMs` defaults to 5000 ms. Models returned by discovery are added alongside explicitly configured models. +`modelInfoFormat` and `smartModelName` are optional: without them, discovered names remain the model IDs and no external metadata is fetched. Supported metadata formats are `aiproxy`, `models.dev`, `bifrost`, `litellm`, `vllm`, `lmstudio`, `llama-swap`, `omniroute`, and `openrouter`. `models.dev` fetches `https://models.dev/models.json` by default; `modelInfoEndpoint` can override the catalog URL. The `aiproxy` format composes that catalog with inline metadata from AIProxy's model list. The `openrouter` format reads OpenRouter's inline `/v1/models` metadata without another request. `filterNonChat` defaults to `true`; it can filter non-chat models when the selected metadata source supplies that information. `timeoutMs` defaults to 5000 ms. Models returned by discovery are added alongside explicitly configured models. For a local build, run `npm run compile` and replace the package value above with `"file:///absolute/path/to/opencode-models-discovery/dist"`. OpenCode v2 expects a **directory**, not a path to `index.js` or `server.js`. After rebuilding plugin code, run `opencode service restart` so the background service loads the new bundle. Discovery runs when the plugin initializes. The native TUI commands are `/models-discovery-refresh` and `/models-discovery-console`; the console combines Provider → model → model details browsing with cache inspection and clearing. Use `/models-discovery-console status --provider ` to filter the browser. The OpenCode v2 agent tools `models_discovery_refresh` and `models_discovery_status` remain available for automation. Pass `rawCache: true` to `models_discovery_status` to include cached raw models and enrichment results. diff --git a/docs/configuration.md b/docs/configuration.md index 961953b1..460ff098 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,7 +101,7 @@ Each provider can configure discovery behavior through `provider..options. | `provider..options.modelsDiscovery.endpoint` | `string` | Provider-specific models endpoint as an origin-relative path beginning with `/`. Defaults to `/v1/models` | | `provider..options.modelsDiscovery.timeoutMs` | positive finite `number` | Per-request timeout for the provider's models and provider-specific metadata endpoints. Defaults to `3000` | | `provider..options.modelsDiscovery.modelInfoEndpoint` | `string` | Override a format-specific metadata endpoint as an origin-relative path or complete URL. Defaults to `/v1/model/info` for `"litellm"` and `/api/v1/models` for `"lmstudio"`; for `"aiproxy"`, it overrides the models.dev fallback URL | -| `provider..options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, and `"omniroute"` | +| `provider..options.modelsDiscovery.modelInfoFormat` | `string` | Model info response format. Currently supports `"aiproxy"`, `"bifrost"`, `"litellm"`, `"models.dev"`, `"vllm"`, `"lmstudio"`, `"llama-swap"`, `"omniroute"`, and `"openrouter"` | | `provider..options.modelsDiscovery.filterNonChat` | `boolean` | When model info is available, skip models whose `model_info.mode` is not `chat`. Defaults to `true` | | `provider..options.modelsDiscovery.models.includeRegex` | `string[]` | Shortcut regex allow-list for discovered model ids only | | `provider..options.modelsDiscovery.models.excludeRegex` | `string[]` | Shortcut regex deny-list for discovered model ids only | @@ -339,7 +339,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 | |--------|--------|------------------------------|-------| @@ -351,6 +351,7 @@ The plugin currently supports eight model info formats: | `"lmstudio"` | LM Studio 0.4.0+ `/api/v1/models` inventory | No | Uses `/api/v1/models` by default; set `modelInfoEndpoint` for another path | | `"llama-swap"` | Fields in llama-swap's `/v1/models` response | No | Reads inline context, modalities, and function-calling metadata when present | | `"omniroute"` | Fields in OmniRoute's `/v1/models` response | No | Reads OmniRoute inline limits, modalities, and capabilities when present | +| `"openrouter"` | Fields in OpenRouter's `/v1/models` response | No | Reads OpenRouter inline limits, modalities, pricing, capabilities, and reasoning metadata when present | ### AIProxy Metadata @@ -462,6 +463,34 @@ When OmniRoute advertises service-tier metadata through `capabilities.service_ti This format is intentionally explicit because these fields are OmniRoute extensions to the generic OpenAI-compatible model-list response. For the most complete OmniRoute integration, including dynamic provider support, use OmniRoute's official `@omniroute/opencode-plugin`. +### OpenRouter Model Info + +Use `modelInfoFormat: "openrouter"` for an [OpenRouter](https://openrouter.ai/) provider. It reads OpenRouter's inline metadata from the same `/v1/models` response and does not make another metadata request. + +```json +{ + "plugin": ["opencode-models-discovery"], + "provider": { + "openrouter": { + "npm": "@ai-sdk/openai-compatible", + "name": "OpenRouter", + "options": { + "baseURL": "https://openrouter.ai/api/v1", + "apiKey": "YOUR_OPENROUTER_API_KEY", + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "openrouter", + "smartModelName": true + } + }, + "models": {} + } + } +} +``` + +For each discovered model, the plugin maps `context_length` and `top_provider.max_completion_tokens` to context and output limits, `architecture` modalities to OpenCode modalities, and OpenRouter's prompt/completion and cache pricing to per-million-token costs. It maps `supported_parameters` entries for tools and temperature, and converts `reasoning.supported_efforts` into reasoning variants. A non-empty `name` is used as the display name when `smartModelName` is enabled. Missing or malformed metadata is left unset without preventing discovery. + ### Bifrost Model Info Use `modelInfoFormat: "bifrost"` for a Bifrost AI Gateway provider. It reads Bifrost's documented inline metadata from the same `/v1/models` response and does not make another metadata request. diff --git a/docs/v1-v2-shared-core-refactor-prd.md b/docs/v1-v2-shared-core-refactor-prd.md index ac8379f3..6b76dcdb 100644 --- a/docs/v1-v2-shared-core-refactor-prd.md +++ b/docs/v1-v2-shared-core-refactor-prd.md @@ -325,7 +325,7 @@ Owner resolution has been unified into `resolveModelOwner` in `src/core/model-na ### 5. V1 and V2 still duplicate model-info enricher resolution (Resolved in Phase 5) -Enricher resolution has been extracted into `resolveModelInfoEnricher` in `src/core/enricher-resolver.ts`. Both V1 and V2 adapters share the exact same resolution rules for static index formats (`models.dev`, `aiproxy`), inline/zero-request formats (`bifrost`, `vllm`, `llama-swap`, `omniroute`), and remote provider endpoints (`litellm`, `lmstudio`). Credentials and transport bindings remain host-owned while format handling is 100% unified. +Enricher resolution has been extracted into `resolveModelInfoEnricher` in `src/core/enricher-resolver.ts`. Both V1 and V2 adapters share the exact same resolution rules for static index formats (`models.dev`, `aiproxy`), inline/zero-request formats (`bifrost`, `vllm`, `llama-swap`, `omniroute`, `openrouter`), and remote provider endpoints (`litellm`, `lmstudio`). Credentials and transport bindings remain host-owned while format handling is 100% unified. ### 6. Existing parity tests do not cover both complete adapter projections (Resolved in Phase 5) diff --git a/src/core/enricher-resolver.ts b/src/core/enricher-resolver.ts index d1d9e5b0..c395fb2c 100644 --- a/src/core/enricher-resolver.ts +++ b/src/core/enricher-resolver.ts @@ -73,7 +73,8 @@ export async function resolveModelInfoEnricher( format === ModelInfoFormat.Bifrost || format === ModelInfoFormat.VLLM || format === ModelInfoFormat.LlamaSwap || - format === ModelInfoFormat.OmniRoute + format === ModelInfoFormat.OmniRoute || + format === ModelInfoFormat.OpenRouter ) { return createModelEnricher(format, null) } diff --git a/src/plugin/commands.ts b/src/plugin/commands.ts index 2b16f642..d3b9e205 100644 --- a/src/plugin/commands.ts +++ b/src/plugin/commands.ts @@ -126,6 +126,7 @@ Supported plugin options under provider..options.modelsDiscovery: - modelInfoFormat="lmstudio": discover callable models through the normal /v1/models endpoint, then enrich exact model-id matches from LM Studio's /api/v1/models inventory - modelInfoFormat="llama-swap": read llama-swap's inline /v1/models context, modalities, and function-calling metadata without another request - modelInfoFormat="omniroute": read OmniRoute's documented inline /v1/models limits, modalities, and capabilities without another request +- modelInfoFormat="openrouter": read OpenRouter's inline /v1/models limits, modalities, pricing, capabilities, and reasoning metadata without another request - filterNonChat: when LiteLLM model info is available, skip non-chat models by default - cache.enabled: opt in to a provider-scoped persisted discovery cache of filtered and enriched model configurations; defaults to false - cache.ttlSeconds: non-negative finite cache lifetime in seconds; defaults to 86400 @@ -153,6 +154,7 @@ Recommended defaults: - use modelInfoFormat="lmstudio" for LM Studio REST metadata enrichment; set modelInfoEndpoint only when LM Studio uses another inventory path - use modelInfoFormat="llama-swap" only for llama-swap /v1/models responses; it is an explicit inline-metadata format and does not make another request - use modelInfoFormat="omniroute" only for OmniRoute's documented /v1/models response; it is an explicit inline-metadata format and does not make another request +- use modelInfoFormat="openrouter" only for OpenRouter's /v1/models response; it is an explicit inline-metadata format and does not make another request - configure caching only when the user requests it; it is disabled by default - the persisted discovery cache belongs to this plugin and is not a replacement for opencode.json diff --git a/src/types/plugin-config.ts b/src/types/plugin-config.ts index fd4105f4..d529e404 100644 --- a/src/types/plugin-config.ts +++ b/src/types/plugin-config.ts @@ -38,6 +38,7 @@ export enum ModelInfoFormat { LMStudio = 'lmstudio', LlamaSwap = 'llama-swap', OmniRoute = 'omniroute', + OpenRouter = 'openrouter', } export const DEFAULT_CACHE_TTL_SECONDS = 86400 diff --git a/src/utils/model-info/index.ts b/src/utils/model-info/index.ts index 79350df5..0273fd03 100644 --- a/src/utils/model-info/index.ts +++ b/src/utils/model-info/index.ts @@ -6,6 +6,7 @@ import { createModelsDevEnricher } from './models-dev' import { createOmniRouteEnricher } from './omniroute' import { createVLLMEnricher } from './vllm' import { createAIProxyEnricher } from './aiproxy' +import { createOpenRouterEnricher } from './openrouter' import { ModelInfoFormat } from '../../types/plugin-config' import type { ModelEnricher, ModelEnrichmentContext } from '../../core/model-enrichment' @@ -20,6 +21,7 @@ const MODEL_ENRICHERS: Partial> = [ModelInfoFormat.LMStudio]: (data) => createLMStudioEnricher(data), [ModelInfoFormat.LiteLLM]: (data) => createLiteLLMEnricher(data), [ModelInfoFormat.AIProxy]: (data) => createAIProxyEnricher(data), + [ModelInfoFormat.OpenRouter]: (data) => createOpenRouterEnricher(data), } /** Creates the neutral contract used by the shared discovery pipeline. */ diff --git a/src/utils/model-info/openrouter.ts b/src/utils/model-info/openrouter.ts new file mode 100644 index 00000000..f01108fb --- /dev/null +++ b/src/utils/model-info/openrouter.ts @@ -0,0 +1,117 @@ +import type { ModelEnricher } from '../../core/model-enrichment' +import { createModelLimits } from '../../core/model-types' + +const SUPPORTED_MODALITIES = new Set(['text', 'audio', 'image', 'video', 'pdf']) +const SUPPORTED_REASONING_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + +function record(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + +function usableNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function nonNegativeCostPerMillion(value: unknown): number | undefined { + const parsed = typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' ? Number(value) : undefined + + if (typeof parsed !== 'number' || !Number.isFinite(parsed) || parsed < 0) return undefined + const perMillion = parsed * 1_000_000 + return Number.isFinite(perMillion) ? Number(perMillion.toPrecision(15)) : undefined +} + +function modalities(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + + const result = [...new Set(value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .map((item) => item === 'speech' ? 'audio' : item) + .filter((item) => SUPPORTED_MODALITIES.has(item)))] + + return result.length > 0 ? result : undefined +} + +function reasoningVariants(value: unknown): Record | undefined { + const reasoning = record(value) + if (!Array.isArray(reasoning?.supported_efforts)) return undefined + + const variants: Record = {} + for (const effort of reasoning.supported_efforts) { + if (typeof effort !== 'string') continue + const normalized = effort.trim().toLowerCase() + if (SUPPORTED_REASONING_EFFORTS.has(normalized)) { + variants[normalized] = { reasoningEffort: normalized } + } + } + + return Object.keys(variants).length > 0 ? variants : undefined +} + +export function createOpenRouterEnricher(_data: unknown): ModelEnricher { + return { + enrich(model) { + const result: Record = {} + const name = model.name + if (typeof name === 'string' && name.trim().length > 0) { + result.metadataName = name.trim() + } + + const context = usableNumber(model.context_length) ? model.context_length : undefined + const topProvider = record(model.top_provider) + const output = usableNumber(topProvider?.max_completion_tokens) + ? topProvider.max_completion_tokens + : undefined + if (context) { + result.limit = createModelLimits(context, output) + } + + const architecture = record(model.architecture) + const inputModalities = modalities(architecture?.input_modalities) + const outputModalities = modalities(architecture?.output_modalities) + if (inputModalities || outputModalities) { + result.modalities = { + ...(inputModalities ? { input: inputModalities } : {}), + ...(outputModalities ? { output: outputModalities } : {}), + } + } + + const pricing = record(model.pricing) + const input = nonNegativeCostPerMillion(pricing?.prompt) + const outputCost = nonNegativeCostPerMillion(pricing?.completion) + const cacheRead = nonNegativeCostPerMillion(pricing?.input_cache_read) + const cacheWrite = nonNegativeCostPerMillion(pricing?.input_cache_write) + if (input !== undefined && outputCost !== undefined) { + result.cost = { + input, + output: outputCost, + ...(cacheRead !== undefined ? { cache_read: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cache_write: cacheWrite } : {}), + } + } + + const supportedParameters = Array.isArray(model.supported_parameters) + ? model.supported_parameters.filter((item): item is string => typeof item === 'string') + : [] + if (supportedParameters.includes('tools') || supportedParameters.includes('tool_choice')) { + result.toolCall = true + } + if (supportedParameters.length > 0) { + result.temperature = supportedParameters.includes('temperature') + } + + const reasoning = record(model.reasoning) + const variants = reasoningVariants(reasoning) + if (variants) result.variants = variants + if (reasoning?.mandatory === true || reasoning?.default_enabled === true || variants) { + result.reasoning = true + } + + return result + }, + } +} diff --git a/test/core/enricher-resolver.test.ts b/test/core/enricher-resolver.test.ts index 2b0431b7..6eaa4953 100644 --- a/test/core/enricher-resolver.test.ts +++ b/test/core/enricher-resolver.test.ts @@ -22,6 +22,17 @@ describe('shared enricher resolver', () => { expect(fetcher).not.toHaveBeenCalled() }) + it('resolves OpenRouter inline metadata without a network request', async () => { + const fetcher = vi.fn() + const enricher = await resolveModelInfoEnricher( + { baseURL: 'https://openrouter.ai/api/v1', fetcher }, + { format: ModelInfoFormat.OpenRouter }, + ) + + expect(enricher).toBeDefined() + expect(fetcher).not.toHaveBeenCalled() + }) + it('resolves remote LiteLLM format with authentication and timeout', async () => { const fetcher = vi.fn().mockResolvedValue({ ok: true, diff --git a/test/core/model-parity.test.ts b/test/core/model-parity.test.ts index 3691a77b..efacda65 100644 --- a/test/core/model-parity.test.ts +++ b/test/core/model-parity.test.ts @@ -8,6 +8,7 @@ import { createBifrostEnricher } from '../../src/utils/model-info/bifrost' import { createVLLMEnricher } from '../../src/utils/model-info/vllm' import { createLlamaSwapEnricher } from '../../src/utils/model-info/llamaswap' import { createOmniRouteEnricher } from '../../src/utils/model-info/omniroute' +import { createOpenRouterEnricher } from '../../src/utils/model-info/openrouter' import { createLMStudioEnricher } from '../../src/utils/model-info/lmstudio' import { createAIProxyEnricher } from '../../src/utils/model-info/aiproxy' @@ -189,6 +190,38 @@ describe('V1/V2 shared-core parity', () => { expect(mapped.reasoning).toBe(true) }) + it('keeps native OpenRouter enrichment equivalent for V1 drafts and V2 models', () => { + const drafts = discoverModelDrafts([{ + id: 'openai/gpt-6.1-sol', + name: 'OpenAI: GPT-6.1 Sol', + context_length: 1_048_576, + top_provider: { max_completion_tokens: 65_536 }, + architecture: { input_modalities: ['text', 'image'], output_modalities: ['text'] }, + pricing: { prompt: '0.000002', completion: '0.00001' }, + supported_parameters: ['tools'], + reasoning: { supported_efforts: ['low', 'medium', 'high'] }, + }], { + filter: { includeBy: [], excludeBy: [], includeRegex: [], excludeRegex: [] }, + smartModelName: true, + enricher: createOpenRouterEnricher(null), + enrichmentContext: { filterNonChat: true }, + }) + const draft = drafts[0]! + const mapped = mapToDiscoveredV2Model(draft, options) + + expect(draft.name).toBe('OpenAI: GPT-6.1 Sol') + expect(draft.limit).toEqual({ context: 1_048_576, output: 65_536 }) + expect(draft.modalities).toEqual({ input: ['text', 'image'], output: ['text'] }) + expect(draft.cost).toEqual({ input: 2, output: 10 }) + expect(draft.toolCall).toBe(true) + expect(draft.reasoning).toBe(true) + expect(mapped.name).toBe(draft.name) + expect(mapped.limit).toEqual(draft.limit) + expect(mapped.capabilities.input).toEqual(['text', 'image']) + expect(mapped.capabilities.tools).toBe(true) + expect(mapped.cost).toEqual([{ input: 2, output: 10, cache: { read: 0, write: 0 } }]) + }) + it('keeps native LM Studio enrichment equivalent for V1 drafts and V2 models', () => { const enricher = createLMStudioEnricher({ models: [{ key: 'lmstudio/gemma', diff --git a/test/openrouter-model-info.test.ts b/test/openrouter-model-info.test.ts new file mode 100644 index 00000000..295a62f6 --- /dev/null +++ b/test/openrouter-model-info.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { createOpenRouterEnricher } from '../src/utils/model-info/openrouter' + +describe('OpenRouter model info enricher', () => { + it('extracts inline metadata from an OpenRouter model entry', () => { + const result = createOpenRouterEnricher(null).enrich({ + id: 'openai/gpt-6.1-sol', + name: 'OpenAI: GPT-6.1 Sol', + context_length: 1_048_576, + architecture: { + input_modalities: ['text', 'image'], + output_modalities: ['text'], + }, + pricing: { + prompt: '0.000002', + completion: '0.00001', + input_cache_read: '0.0000001', + input_cache_write: '0.0000001875', + }, + top_provider: { + max_completion_tokens: 65_536, + }, + supported_parameters: ['temperature', 'tools', 'reasoning_effort'], + reasoning: { + mandatory: true, + supported_efforts: ['low', 'medium', 'high'], + }, + }, { filterNonChat: true }) + + expect(result).toEqual({ + metadataName: 'OpenAI: GPT-6.1 Sol', + limit: { context: 1_048_576, output: 65_536 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + cost: { + input: 2, + output: 10, + cache_read: 0.1, + cache_write: 0.1875, + }, + reasoning: true, + variants: { + low: { reasoningEffort: 'low' }, + medium: { reasoningEffort: 'medium' }, + high: { reasoningEffort: 'high' }, + }, + toolCall: true, + temperature: true, + }) + }) + + it('leaves malformed optional metadata unset', () => { + const result = createOpenRouterEnricher(null).enrich({ + id: 'provider/model', + name: '', + context_length: 0, + architecture: { input_modalities: ['unsupported'] }, + pricing: { prompt: 'invalid', completion: -1 }, + top_provider: { max_completion_tokens: '8192' }, + reasoning: { supported_efforts: ['ultra'] }, + }, { filterNonChat: true }) + + expect(result).toEqual({}) + }) +}) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 691a9d68..a58c8046 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -281,6 +281,7 @@ describe('ModelDiscovery Plugin', () => { expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="lmstudio"') expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="llama-swap"') expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="omniroute"') + expect(config.command['models-discovery:config'].template).toContain('modelInfoFormat="openrouter"') expect(config.command['models-discovery:config'].template).toContain('max_model_len') expect(config.command['models-discovery:config'].template).toContain('restart opencode') From cbee3e14ce15115bf124192fb31ba23071ac2aca Mon Sep 17 00:00:00 2001 From: yuhp Date: Mon, 19 Oct 2026 01:09:59 +0800 Subject: [PATCH 08/10] fix(v2): prevent stale settings resurrection on config reload - Omit cached provider settings in editor.add inside createProviderController to prevent replaying stale settings during state rebuild - Add regression test in test/v2/catalog.test.ts - Document root cause analysis, execution order, and issue resolution in docs/issues/opencode-v2-settings-merge-on-reload.md --- .../opencode-v2-settings-merge-on-reload.md | 48 ++++++++++++++++++- src/v2/catalog.ts | 1 - test/v2/catalog.test.ts | 21 ++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/issues/opencode-v2-settings-merge-on-reload.md b/docs/issues/opencode-v2-settings-merge-on-reload.md index 0b061a2e..86396839 100644 --- a/docs/issues/opencode-v2-settings-merge-on-reload.md +++ b/docs/issues/opencode-v2-settings-merge-on-reload.md @@ -1,5 +1,47 @@ # OpenCode v2 Issue: Provider Settings Merged Rather Than Replaced on Configuration Reload +## Status + +- Reported to upstream OpenCode: [#56834](https://github.com/anomalyco/opencode/issues/56834) (Closed) +- Root cause identified as plugin-side behavior; issue closed upstream with comment. +- Fixed in `src/v2/catalog.ts` by omitting cached `settings` in `editor.add`. + +## Resolution & Root Cause Analysis + +### Initial Misconception +Initially, it appeared that OpenCode host was performing an in-memory `deepMerge` across config reloads without deleting absent keys, because: +- Changing a key value (e.g. `true` -> `false`) took effect immediately. +- Deleting a key left the stale value indefinitely in `GET /api/provider/{id}`. + +### Actual Root Cause +OpenCode core actually creates provider state afresh on every reload from `initial: () => new Map()`. The stale values were being injected by **this plugin's** own transform: + +1. **Plugin Execution Order** (`packages/core/src/plugin/supervisor.ts`): + - Plugin lifecycle boots in order: `internal.pre` -> user plugins (`sdk.all()`, `instance.all()`) -> `internal.post`. + - `ConfigProviderPlugin` is registered in `internal.post`. + - Consequently, on every provider state rebuild, user plugin transforms run **before** `ConfigProviderPlugin`. + +2. **Transform Injection Loop** (`src/v2/catalog.ts`): + - In `createProviderController`, the transform iterated cached providers. When `editor.get(providerID)` returned `undefined` (because state was freshly cleared), it executed `editor.add(...)` with `settings: provider.settings` containing stale settings cached from an earlier `ctx.provider.list()`. + - Next, `ConfigProviderPlugin` ran: `provider.settings = Provider.mergeOverlay(provider.settings, item.settings)`. Since `Provider.mergeOverlay` performs an additive merge, keys not present in the new configuration (`item.settings`) were retained from the plugin-injected draft object. + - Finally, the plugin's `config.updated` handler called `ctx.provider.list()`, read the resurrected keys, and re-cached them, making the staleness permanent until a daemon restart or plugin deactivation. + +### Control Matrix (Verification) + +| Scenario | Edit | Result | +|---|---|---| +| Plugin disabled | delete `customObject.bar` | ✅ gone immediately (3s/8s/15s) | +| Plugin enabled, provider without `modelsDiscovery` | delete `customObject.bar` | ✅ gone immediately | +| Plugin enabled, provider with `modelsDiscovery` (before fix) | delete `smartModelName` | ❌ resurrected indefinitely | +| Same as above, then hot-remove plugin from config | — | ✅ stale key disappears immediately | +| Service restart | — | ✅ clean | + +### Fix Applied +In `src/v2/catalog.ts`: +- Omitted `settings: provider.settings` in `editor.add`. +- Provider `settings` are authoritatively owned by the configuration file and populated by `ConfigProviderPlugin`. The plugin only contributes `headers`, `integrationID`, and discovered `models`. +- Added regression tests in `test/v2/catalog.test.ts`. Verified live that deleting keys from `opencode.json` takes effect immediately on reload. + ## Summary When `opencode.json` is modified while the OpenCode background service is running, OpenCode emits a `config.updated` lifecycle event and updates its in-memory provider registry. However, the host performs an object merge (`deepMerge`) on `providers..settings` instead of replacing the settings object with the new file contents. @@ -83,5 +125,7 @@ When `opencode.json` changes, the provider's `settings` object should reflect th ## Workaround -- Users should explicitly set keys to `false` or their default values (e.g., `"smartModelName": false`) rather than deleting them from `opencode.json`. -- Alternatively, restarting the OpenCode service (`opencode service restart`) forces a clean load from disk. +- **Fixed in plugin**: With the fix in `src/v2/catalog.ts`, settings are no longer injected during `editor.add`, so deleting keys works as expected without workarounds. +- For earlier plugin versions: + - Users should explicitly set keys to `false` or their default values (e.g., `"smartModelName": false`) rather than deleting them from `opencode.json`. + - Alternatively, restarting the OpenCode service (`opencode service restart`) forces a clean load from disk. diff --git a/src/v2/catalog.ts b/src/v2/catalog.ts index 42f3efab..ab15a58b 100644 --- a/src/v2/catalog.ts +++ b/src/v2/catalog.ts @@ -69,7 +69,6 @@ export function createProviderController( ...Provider.Info.empty(providerID), name: provider.name ?? provider.id, package: provider.package, - settings: provider.settings, headers: authHeaders, integrationID: targetIntegrationID as never, activation: "enabled", diff --git a/test/v2/catalog.test.ts b/test/v2/catalog.test.ts index a591213a..5cbdd896 100644 --- a/test/v2/catalog.test.ts +++ b/test/v2/catalog.test.ts @@ -37,6 +37,7 @@ describe("V2 provider controller", () => { controller.transform(editor as never) expect(added[0]?.models).toEqual([]) + expect(added[0]?.info.settings).toBeUndefined() await controller.replaceInventory(inventory()) await controller.replaceInventory(inventory("replacement-model")) @@ -45,6 +46,26 @@ describe("V2 provider controller", () => { expect(controller.status()).toEqual({ providers: 1, models: 1 }) }) + it("omits provider settings in editor.add to prevent stale settings resurrection on config reload", () => { + const added: Array<{ info: Record; models: unknown[] }> = [] + const controller = createProviderController( + { provider: { reload: vi.fn() } } as never, + [provider("test-provider")], + (id) => `integration.${id}`, + ) + const editor = { + get: vi.fn().mockReturnValue(undefined), + add: vi.fn((definition) => added.push(definition)), + update: vi.fn(), + models: { set: vi.fn() }, + } + + controller.transform(editor as never) + expect(added).toHaveLength(1) + expect(added[0]?.info.id).toBe("test-provider") + expect(added[0]?.info.settings).toBeUndefined() + }) + it("preserves existing source models when adding discovered models", async () => { const set = vi.fn() const existing = { modelID: "explicit-model" } From 56684ae23b54cbc0f38687541e5ceb4871805d41 Mon Sep 17 00:00:00 2001 From: yuhp Date: Mon, 19 Oct 2026 11:52:35 +0800 Subject: [PATCH 09/10] docs: finalize v2.0.0 documentation, enrichers, and jsonc configuration --- README.md | 49 +++++-- docs/config_example/README.md | 20 ++- docs/config_example/a2agent.md | 36 +++-- docs/config_example/ai-router.md | 20 +-- docs/config_example/deepseek.md | 22 +-- docs/configuration.md | 227 ++++++++++++++----------------- docs/migrate-v1-to-v2.md | 13 +- docs/providers.md | 2 +- docs/releases/v2.0.0.md | 76 ++++++----- 9 files changed, 252 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 93fc79d5..f1065831 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,13 @@ Originally inspired by [opencode-lmstudio](https://github.com/agustif/opencode-l OpenCode v2 uses **`plugins`** (an array of package names) and **`providers`** (a map). Configure discovery under `providers..settings.modelsDiscovery`; it runs only when `enabled` is explicitly `true`. OpenCode v1 instead uses `plugin`, `provider`, and `options` (see [OpenCode v1 quick start](#opencode-v1-quick-start)). -Add the plugin package to your OpenCode v2 `opencode.json`. OpenCode resolves -and loads the package automatically. +Add the plugin package to your OpenCode v2 `opencode.json` (or `opencode.jsonc`). OpenCode resolves and loads the package automatically: -> **Note on Pre-releases**: During Release Candidate testing, use `"opencode-models-discovery@rc"` (or pin to a specific release like `"opencode-models-discovery@2.0.0-rc.2"`). Once `v2.0.0` is published to `latest`, `"opencode-models-discovery@2.0.0"` and `"opencode-models-discovery@latest"` will resolve directly. - -```json +```jsonc { "$schema": "https://opencode.ai/config.json", "plugins": [ - "opencode-models-discovery@rc" + "opencode-models-discovery@2.0.0" ], "providers": { "gateway-custom": { @@ -45,7 +42,8 @@ and loads the package automatically. "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { "baseURL": "https://gateway.example.com/v1", - "apiKey": "${GATEWAY_API_KEY}", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "{env:GATEWAY_API_KEY}", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "models.dev", @@ -65,7 +63,7 @@ and loads the package automatically. } ``` -Set `GATEWAY_API_KEY` in the environment used to start OpenCode; the `${GATEWAY_API_KEY}` placeholder is resolved by the OpenCode v2 host. Alternatively, omit `apiKey` from `opencode.json` and configure credentials interactively with OpenCode's native `/connect` command. If your endpoint needs no credential, omit `apiKey`. Use the same provider package shown above for OpenAI-compatible discovery. The provider needs a `baseURL`; the plugin fetches its models from `/v1/models` by default (relative to the URL's **origin**). For providers that expose `/models` instead, set `"endpoint": "/models"` inside `modelsDiscovery`. +Set `GATEWAY_API_KEY` in the environment used to start OpenCode; the `{env:GATEWAY_API_KEY}` placeholder is resolved by the OpenCode v2 host. Alternatively, omit `apiKey` from `opencode.json` and configure credentials interactively with OpenCode's native `/connect` command. If your endpoint needs no credential, omit `apiKey`. Use the same provider package shown above for OpenAI-compatible discovery. The provider needs a `baseURL`; the plugin fetches its models from `/v1/models` by default (relative to the URL's **origin**). For providers that expose `/models` instead, set `"endpoint": "/models"` inside `modelsDiscovery`. ### Credentials @@ -262,7 +260,7 @@ For AIProxy's inline model-list metadata, use `modelInfoFormat: "aiproxy"`: { "providers": { "aiproxy": { - "package": "aisdk:@ai-sdk/openai-compatible", + "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { "baseURL": "https://aiproxy.example/api", "apiKey": "{env:AIPROXY_API_KEY}", @@ -305,6 +303,17 @@ For LiteLLM-compatible model info endpoints, `/v1/model/info` is used by default } ``` +For OpenRouter's inline `/v1/models` metadata, without another metadata request: + +```json +{ + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "openrouter" + } +} +``` + For Bifrost AI Gateway's inline `/v1/models` metadata, without another metadata request: ```json @@ -327,6 +336,17 @@ For LM Studio 0.4.0+'s native v1 REST inventory endpoint: } ``` +For vLLM's inline `max_model_len` metadata, without another metadata request: + +```json +{ + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "vllm" + } +} +``` + For llama-swap's inline `/v1/models` metadata, without another metadata request: ```json @@ -338,6 +358,17 @@ For llama-swap's inline `/v1/models` metadata, without another metadata request: } ``` +For OmniRoute's inline `/v1/models` metadata, without another metadata request: + +```json +{ + "modelsDiscovery": { + "enabled": true, + "modelInfoFormat": "omniroute" + } +} +``` + If metadata cannot be fetched or matched safely, discovery still succeeds and the plugin leaves unknown capability fields unset rather than guessing defaults. See [model metadata enrichment](docs/configuration.md#model-metadata-enrichment) for format-specific behavior and configuration. ## Upgrade note diff --git a/docs/config_example/README.md b/docs/config_example/README.md index e524bb30..5284b471 100644 --- a/docs/config_example/README.md +++ b/docs/config_example/README.md @@ -2,10 +2,24 @@ This directory contains community-maintained provider configuration examples for `opencode-models-discovery`. -Examples currently use the OpenCode v1 configuration shape. OpenCode v2 support is currently in beta and uses `plugins`, `providers`, `package`, and `settings`; adapt examples using the [configuration guide](../configuration.md#opencode-v2-configuration-beta-support). +Examples use the primary OpenCode v2 configuration shape (`plugins`, `providers`, `package`, and `settings`). For legacy OpenCode v1 users, adapt examples using the [configuration migration guide](../migrate-v1-to-v2.md). Examples may include provider declarations, non-standard `modelsDiscovery.endpoint` values, provider-level `models.includeBy` and `models.excludeBy` raw-field filters using `equals` or `match`, metadata enrichment options, and notes about provider-specific behavior. Provider-level `models.includeRegex` and `models.excludeRegex` are supported as id-only shortcuts, but examples should prefer `includeBy` and `excludeBy`. +## Self-Hosted Engines, Gateways & Metadata Enrichers + +For local inference engines, self-hosted gateways, and metadata enrichment formats, see the comprehensive [Model Metadata Enrichment](../configuration.md#model-metadata-enrichment) documentation in `docs/configuration.md`. It provides configuration examples and capability mapping details for: + +- **AIProxy**: [AIProxy Metadata](../configuration.md#aiproxy-metadata) (`modelInfoFormat: "aiproxy"`) +- **Bifrost**: [Bifrost Model Info](../configuration.md#bifrost-model-info) (`modelInfoFormat: "bifrost"`) +- **LiteLLM**: [LiteLLM Model Info](../configuration.md#litellm-model-info) (`modelInfoFormat: "litellm"`) +- **LM Studio**: [LM Studio Model Info](../configuration.md#lm-studio-model-info) (`modelInfoFormat: "lmstudio"`) +- **llama-swap**: [llama-swap Model Info](../configuration.md#llama-swap-model-info) (`modelInfoFormat: "llama-swap"`) +- **models.dev**: [models.dev Metadata](../configuration.md#modelsdev-metadata) (`modelInfoFormat: "models.dev"`) +- **OmniRoute**: [OmniRoute Model Info](../configuration.md#omniroute-model-info) (`modelInfoFormat: "omniroute"`) +- **OpenRouter**: [OpenRouter Model Info](../configuration.md#openrouter-model-info) (`modelInfoFormat: "openrouter"`) +- **vLLM**: [vLLM Model Info](../configuration.md#vllm-model-info) (`modelInfoFormat: "vllm"`) + ## Disclaimer This project is not affiliated with, endorsed by, or sponsored by the providers listed in these examples. @@ -16,7 +30,7 @@ Examples are not security, legal, compliance, or purchasing advice. Do not paste secrets into public issues or pull requests. Configure provider credentials with OpenCode `/connect` when possible, or through local private config. -## Examples +## Community Provider Examples - [A2Agent](a2agent.md) - [AI-ROUTER](ai-router.md) @@ -35,7 +49,7 @@ Do not include unrelated changes in provider example PRs. In particular, do not Each example should: -- Use provider-level `provider..options.modelsDiscovery` config for OpenCode v1. OpenCode v2 examples must use `providers..settings.modelsDiscovery`. +- Prefer the primary OpenCode v2 shape (`providers..settings.modelsDiscovery`). - Prefer `models.includeBy` and `models.excludeBy` over `includeRegex` and `excludeRegex`. - Avoid secrets, tokens, API keys, account IDs, or private URLs. - Include a provider-specific disclaimer that this project is not affiliated with, endorsed by, or sponsored by that provider. diff --git a/docs/config_example/a2agent.md b/docs/config_example/a2agent.md index 5bb7acbd..b4ae4ed7 100644 --- a/docs/config_example/a2agent.md +++ b/docs/config_example/a2agent.md @@ -1,27 +1,30 @@ # A2Agent Configuration Example -This example uses the OpenCode v1 configuration shape. OpenCode v2 support is currently in beta; use `plugins`, `providers`, `package`, and `settings` instead. See the [V2 configuration guide](../configuration.md#opencode-v2-configuration-beta-support). +This example uses the primary OpenCode v2 configuration shape. For legacy OpenCode v1, adapt using the [migration guide](../migrate-v1-to-v2.md). ## Disclaimer -This project is not affiliated with, endorsed by, or sponsored by -[A2Agent](https://a2agent.me/). +This project is not affiliated with, endorsed by, or sponsored by [A2Agent](https://a2agent.me/). ## Example -```json +```jsonc { - "provider": { + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-models-discovery@2.0.0" + ], + "providers": { "a2agent": { - "npm": "@ai-sdk/openai-compatible", - "name": "A2Agent", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://api.a2agent.me/v1", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { "enabled": true } - }, - "models": {} + } } } } @@ -29,12 +32,7 @@ This project is not affiliated with, endorsed by, or sponsored by ## Notes -- Configure credentials with OpenCode `/connect` when possible, or through - local private configuration. -- This uses the plugin's standard `/v1/models` discovery path, which resolves - to `https://api.a2agent.me/v1/models`. -- The example intentionally does not declare a static model list, filters, - pricing, or third-party metadata. Returned models depend on the account and - current API response. -- Verify the current API base URL, model availability, pricing, terms of - service, data handling, and regional availability before use. +- Configure credentials with OpenCode `/connect` when possible, or through local private configuration. +- This uses the plugin's standard `/v1/models` discovery path, which resolves to `https://api.a2agent.me/v1/models`. +- The example intentionally does not declare a static model list, filters, pricing, or third-party metadata. Returned models depend on the account and current API response. +- Verify the current API base URL, model availability, pricing, terms of service, data handling, and regional availability before use. diff --git a/docs/config_example/ai-router.md b/docs/config_example/ai-router.md index d467e9db..60ee1453 100644 --- a/docs/config_example/ai-router.md +++ b/docs/config_example/ai-router.md @@ -1,6 +1,6 @@ # AI-ROUTER Configuration Example -This example uses the OpenCode v1 configuration shape. OpenCode v2 support is currently in beta; use `plugins`, `providers`, `package`, and `settings` instead. See the [V2 configuration guide](../configuration.md#opencode-v2-configuration-beta-support). +This example uses the primary OpenCode v2 configuration shape. For legacy OpenCode v1, adapt using the [migration guide](../migrate-v1-to-v2.md). ## Disclaimer @@ -8,19 +8,23 @@ This project is not affiliated with, endorsed by, or sponsored by [AI-ROUTER](ht ## Example -```json +```jsonc { - "provider": { + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-models-discovery@2.0.0" + ], + "providers": { "ai-router": { - "npm": "@ai-sdk/openai-compatible", - "name": "AI-ROUTER", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://api.ai-router.dev/v1", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { "enabled": true } - }, - "models": {} + } } } } diff --git a/docs/config_example/deepseek.md b/docs/config_example/deepseek.md index 10b9018d..01d64691 100644 --- a/docs/config_example/deepseek.md +++ b/docs/config_example/deepseek.md @@ -1,6 +1,6 @@ # DeepSeek Configuration Example -This example uses the OpenCode v1 configuration shape. OpenCode v2 support is currently in beta; use `plugins`, `providers`, `package`, and `settings` instead. See the [V2 configuration guide](../configuration.md#opencode-v2-configuration-beta-support). +This example uses the primary OpenCode v2 configuration shape. For legacy OpenCode v1, adapt using the [migration guide](../migrate-v1-to-v2.md). ## Disclaimer @@ -8,20 +8,24 @@ This project is not affiliated with, endorsed by, or sponsored by DeepSeek. ## Example -```json +```jsonc { - "provider": { + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-models-discovery@2.0.0" + ], + "providers": { "deepseek": { - "npm": "@ai-sdk/openai-compatible", - "name": "DeepSeek", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://api.deepseek.com/", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { - "endpoint": "/models", + "endpoint": "/models", // DeepSeek uses /models instead of /v1/models "modelInfoFormat": "models.dev" } - }, - "models": {} + } } } } diff --git a/docs/configuration.md b/docs/configuration.md index 278d8deb..247c76f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,30 +10,30 @@ For a step-by-step conversion between the two configuration formats, see [Migrat OpenCode v2 uses `plugins` and `providers`. The plugin can be declared directly as a package name in `plugins`. Discovery configuration is read from `providers..settings.modelsDiscovery`; it is not read from `provider..options.modelsDiscovery` or `plugins[].options.providers`. -> **Note on Pre-releases**: During Release Candidate testing, configure `"opencode-models-discovery@rc"` (or pin to `"opencode-models-discovery@2.0.0-rc.2"`). Once `v2.0.0` is published to `latest`, `"opencode-models-discovery@2.0.0"` or `"opencode-models-discovery@latest"` will resolve directly. - -```json +```jsonc { "$schema": "https://opencode.ai/config.json", "plugins": [ - "opencode-models-discovery@rc" + "opencode-models-discovery@2.0.0" ], "providers": { - "lmstudio": { - "name": "LM Studio", + "my-provider": { + "name": "My AI Provider", "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { - "baseURL": "http://127.0.0.1:1234/v1", + "baseURL": "https://api.example.com/v1", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "{env:PROVIDER_API_KEY}", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "models.dev", "smartModelName": true, "cache": { "enabled": true, - "ttlSeconds": 86400 + "ttlSeconds": 86400 // Cache for 24 hours }, "models": { - "includeBy": [{ "field": "id", "match": "^llama" }] + "includeBy": [{ "field": "id", "match": "^gpt-" }] } } } @@ -208,7 +208,7 @@ For a matching model id, the discovered or cached configuration is the base and OpenCode v2 uses `providers` and represents model variants as an array of entries with `id` and `settings` fields: -```json +```jsonc { "providers": { "gateway": { @@ -369,18 +369,20 @@ The plugin currently supports nine model info formats: | `"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 | +> **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"`). + ### AIProxy Metadata Use `modelInfoFormat: "aiproxy"` when the configured model-list endpoint returns AIProxy's inline `limits` and `pricing` fields. The plugin makes no second request to AIProxy. It also fetches models.dev for general metadata and display names, then overlays valid AIProxy values. Explicit model configuration remains highest priority. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "aiproxy": { - "npm": "@ai-sdk/openai-compatible", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://aiproxy.example/api", + // Optional: omit if managing credentials via OpenCode '/connect' "apiKey": "{env:AIPROXY_API_KEY}", "modelsDiscovery": { "enabled": true, @@ -427,21 +429,18 @@ Recognized effort tiers are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, Use `modelInfoFormat: "llama-swap"` for a [llama-swap](https://github.com/mostlygeek/llama-swap) provider. It reads llama-swap's inline metadata from the same `/v1/models` response and does not make another metadata request. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "llama-swap": { - "npm": "@ai-sdk/openai-compatible", - "name": "llama-swap", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:8080/v1", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "llama-swap" } - }, - "models": {} + } } } } @@ -453,21 +452,18 @@ For each discovered model, the plugin maps `context_length` to `limit.context`, Use `modelInfoFormat: "omniroute"` for an [OmniRoute](https://github.com/diegosouzapw/OmniRoute) provider. It reads OmniRoute's documented inline metadata from the same `/v1/models` response and does not make another metadata request. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "omniroute": { - "npm": "@ai-sdk/openai-compatible", - "name": "OmniRoute", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:20128/v1", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "omniroute" } - }, - "models": {} + } } } } @@ -483,23 +479,21 @@ This format is intentionally explicit because these fields are OmniRoute extensi Use `modelInfoFormat: "openrouter"` for an [OpenRouter](https://openrouter.ai/) provider. It reads OpenRouter's inline metadata from the same `/v1/models` response and does not make another metadata request. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "openrouter": { - "npm": "@ai-sdk/openai-compatible", - "name": "OpenRouter", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://openrouter.ai/api/v1", - "apiKey": "YOUR_OPENROUTER_API_KEY", + // Optional: omit if managing credentials via OpenCode '/connect' + "apiKey": "{env:OPENROUTER_API_KEY}", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "openrouter", "smartModelName": true } - }, - "models": {} + } } } } @@ -511,21 +505,18 @@ For each discovered model, the plugin maps `context_length` and `top_provider.ma Use `modelInfoFormat: "bifrost"` for a Bifrost AI Gateway provider. It reads Bifrost's documented inline metadata from the same `/v1/models` response and does not make another metadata request. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "bifrost": { - "npm": "@ai-sdk/openai-compatible", - "name": "Bifrost", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:8080/v1", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "bifrost" } - }, - "models": {} + } } } } @@ -543,22 +534,19 @@ LiteLLM exposes a richer `/v1/model/info` endpoint in addition to the OpenAI-com Set `modelInfoFormat` to `"litellm"` to enable it. The plugin requests `/v1/model/info` by default; set `modelInfoEndpoint` only when the provider uses another path. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "litellm": { - "npm": "@ai-sdk/openai-compatible", - "name": "LiteLLM", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:4000/v1", "modelsDiscovery": { "enabled": true, "endpoint": "/v1/models", "modelInfoFormat": "litellm" } - }, - "models": {} + } } } } @@ -581,21 +569,18 @@ When model info is available, the plugin uses LiteLLM `model_info` fields to pop Use `modelInfoFormat: "vllm"` for a vLLM-compatible provider whose `/v1/models` response includes a numeric `max_model_len` field for each model. This does not make another metadata request. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "local-vllm": { - "npm": "@ai-sdk/openai-compatible", - "name": "Local vLLM", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:8000/v1", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "vllm" } - }, - "models": {} + } } } } @@ -609,21 +594,18 @@ For each discovered model with a positive numeric `max_model_len`, the plugin se Use `modelInfoFormat: "lmstudio"` with LM Studio 0.4.0+, which officially released the native v1 REST API and `GET /api/v1/models`, to discover models through `/v1/models` and enrich them from `/api/v1/models`. Set `modelInfoEndpoint` only when LM Studio uses another inventory path. -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "lmstudio": { - "npm": "@ai-sdk/openai-compatible", - "name": "LM Studio", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:1234/v1", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "lmstudio" } - }, - "models": {} + } } } } @@ -641,22 +623,20 @@ This project is not affiliated with, endorsed by, or sponsored by [models.dev](h This does not require `modelInfoEndpoint`, because the source is fixed to `https://models.dev/models.json`: -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "providers": { "openrouter": { - "npm": "@ai-sdk/openai-compatible", - "name": "OpenRouter", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://openrouter.ai/api/v1", - "apiKey": "YOUR_OPENROUTER_API_KEY", + // Optional: omit if managing credentials via OpenCode '/connect' + "apiKey": "{env:OPENROUTER_API_KEY}", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "models.dev" } - }, - "models": {} + } } } } @@ -686,12 +666,12 @@ Because this option makes a public network request to models.dev during discover For providers with custom metadata paths or non-standard behavior: -```json +```jsonc { - "provider": { + "providers": { "custom": { - "npm": "@ai-sdk/openai-compatible", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:9000/v1", "modelsDiscovery": { "modelInfoEndpoint": "/custom/model-info", @@ -708,14 +688,16 @@ For providers with custom metadata paths or non-standard behavior: ### Mixed Providers -```json +```jsonc { - "plugin": ["opencode-models-discovery"], - "provider": { + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-models-discovery@2.0.0" + ], + "providers": { "lmstudio": { - "npm": "@ai-sdk/openai-compatible", - "name": "LM Studio", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:1234/v1", "modelsDiscovery": { "enabled": true, @@ -727,22 +709,20 @@ For providers with custom metadata paths or non-standard behavior: }, "smartModelName": true } - }, - "models": {} + } }, "deepseek": { - "npm": "@ai-sdk/openai-compatible", - "name": "DeepSeek", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://api.deepseek.com", - "apiKey": "sk-example-deepseek-key", + // Optional: omit if managing credentials via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { "enabled": true, - "endpoint": "/models", + "endpoint": "/models", // DeepSeek uses /models instead of /v1/models "smartModelName": true } - }, - "models": {} + } } } } @@ -753,19 +733,20 @@ In this example: 1. `lmstudio` explicitly enables discovery and uses the default `/v1/models` endpoint. 2. `lmstudio` limits discovery to model ids matching `^gpt-` with `includeBy`. 3. `deepseek` explicitly enables discovery but uses `"/models"` instead of `/v1/models`. -4. The API key uses an example placeholder and should be replaced in real configs. +4. Credentials can be managed via environment variables or OpenCode `/connect`. ### Provider-First Style -```json +```jsonc { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-models-discovery"], - "provider": { + "plugins": [ + "opencode-models-discovery@2.0.0" + ], + "providers": { "ollama": { - "npm": "@ai-sdk/openai-compatible", - "name": "Ollama", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "http://127.0.0.1:11434/v1", "modelsDiscovery": { "enabled": true, @@ -778,11 +759,11 @@ In this example: } }, "deepseek": { - "npm": "@ai-sdk/openai-compatible", - "name": "DeepSeek", - "options": { + "package": "@opencode-ai/ai/providers/openai-compatible", + "settings": { "baseURL": "https://api.deepseek.com", - "apiKey": "YOUR_DEEPSEEK_API_KEY", + // Optional: omit if managing credentials via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { "enabled": true, "endpoint": "/models", @@ -805,18 +786,18 @@ In this example: For new configs, enable or disable discovery on the provider itself: -```json +```jsonc { - "provider": { + "providers": { "ollama": { - "options": { + "settings": { "modelsDiscovery": { "enabled": true } } }, "lmstudio": { - "options": { + "settings": { "modelsDiscovery": { "enabled": false } @@ -850,20 +831,20 @@ Legacy plugin-level provider filters are ignored in `1.0.0` and are shown here o Control which discovered models are auto-injected with provider-level field filters: -| Option | Type | Description | +| Option (V2: `settings` / V1: `options`) | Type | Description | |--------|------|-------------| -| `provider..options.modelsDiscovery.models.includeBy` | `{ field: string, equals: string \| number \| boolean \| null }[]` or `{ field: string, match: string }[]` | If non-empty, only discovered models matching at least one rule will be added for this provider | -| `provider..options.modelsDiscovery.models.excludeBy` | `{ field: string, equals: string \| number \| boolean \| null }[]` or `{ field: string, match: string }[]` | Discovered models matching any rule will be skipped for this provider | -| `provider..options.modelsDiscovery.models.includeRegex` | `string[]` | Id-only shortcut for `includeBy` with `field: "id"` and `match` | -| `provider..options.modelsDiscovery.models.excludeRegex` | `string[]` | Id-only shortcut for `excludeBy` with `field: "id"` and `match` | +| `providers..settings.modelsDiscovery.models.includeBy` | `{ field: string, equals: string \| number \| boolean \| null }[]` or `{ field: string, match: string }[]` | If non-empty, only discovered models matching at least one rule will be added for this provider | +| `providers..settings.modelsDiscovery.models.excludeBy` | `{ field: string, equals: string \| number \| boolean \| null }[]` or `{ field: string, match: string }[]` | Discovered models matching any rule will be skipped for this provider | +| `providers..settings.modelsDiscovery.models.includeRegex` | `string[]` | Id-only shortcut for `includeBy` with `field: "id"` and `match` | +| `providers..settings.modelsDiscovery.models.excludeRegex` | `string[]` | Id-only shortcut for `excludeBy` with `field: "id"` and `match` | Filtering only applies to auto-discovered models. Models already explicitly configured by the user are preserved. -```json +```jsonc { - "provider": { + "providers": { "ollama": { - "options": { + "settings": { "modelsDiscovery": { "models": { "includeBy": [ diff --git a/docs/migrate-v1-to-v2.md b/docs/migrate-v1-to-v2.md index a2ea91ad..cbd3fb4c 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@rc"]` (or `@2.0.0` once released) | +| `plugin: ["opencode-models-discovery"]` | `plugins: ["opencode-models-discovery@2.0.0"]` | | `provider` | `providers` | | `provider..npm` | `providers..package` | | `provider..options` | `providers..settings` | @@ -60,11 +60,11 @@ The V1 package commonly uses: ## After: OpenCode v2 -```json +```jsonc { "$schema": "https://opencode.ai/config.json", "plugins": [ - "opencode-models-discovery@rc" + "opencode-models-discovery@2.0.0" ], "providers": { "gateway": { @@ -72,7 +72,8 @@ The V1 package commonly uses: "name": "My Gateway", "settings": { "baseURL": "https://gateway.example.com/v1", - "apiKey": "${GATEWAY_API_KEY}", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "{env:GATEWAY_API_KEY}", "modelsDiscovery": { "enabled": true, "modelInfoFormat": "models.dev", @@ -128,8 +129,8 @@ OpenCode v2 uses its provider registry and keeps discovered models in the backgr Do not copy real API keys into a public configuration example. Prefer an environment placeholder resolved by OpenCode v2: -```json -"apiKey": "${GATEWAY_API_KEY}" +```jsonc +"apiKey": "{env:GATEWAY_API_KEY}" ``` Alternatively, omit `apiKey` entirely and use OpenCode v2's native `/connect` command. When you connect a provider via `/connect`, the plugin resolves credentials dynamically through OpenCode v2's integration API and injects runtime authentication headers. OpenCode v2 uses this native integration architecture instead of reading V1 `auth.json` files from disk. See [Connect and Auth Documentation](connect-and-auth.md) for full details. diff --git a/docs/providers.md b/docs/providers.md index eaecd284..4eb4d4c6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,6 +1,6 @@ # Provider Compatibility -This page primarily documents the OpenCode v1 provider shape. OpenCode v2 support is currently in beta and uses `plugins` plus `providers..settings`; see the [OpenCode v2 configuration](configuration.md#opencode-v2-configuration-beta-support) section before using these examples with OpenCode v2. In V2, `modelsDiscovery.enabled: true` explicitly opts a provider into a model-list request regardless of its package name; the backend must expose an OpenAI-compatible model-list endpoint. +This page primarily documents the OpenCode v1 provider shape. OpenCode v2 is the stable, primary integration and uses `plugins` plus `providers..settings`; see the [OpenCode v2 configuration](configuration.md#opencode-v2-configuration-stable-support) section before using these examples with OpenCode v2. In V2, `modelsDiscovery.enabled: true` explicitly opts a provider into a model-list request regardless of its package name; the backend must expose an OpenAI-compatible model-list endpoint. ## Supported Providers diff --git a/docs/releases/v2.0.0.md b/docs/releases/v2.0.0.md index 927db39c..1ce61c2e 100644 --- a/docs/releases/v2.0.0.md +++ b/docs/releases/v2.0.0.md @@ -2,22 +2,29 @@ ## Stable OpenCode v2 support -`opencode-models-discovery@2.0.0` makes OpenCode v2 the primary and stable -integration. V2 uses the native `plugins` / `providers` configuration shape, -the provider lifecycle API, host-managed storage, Integration credentials, the -native TUI, RPC handlers, and Agent Tools. +`opencode-models-discovery@2.0.0` promotes OpenCode v2 to the primary, stable integration. V2 uses the native `plugins` / `providers` configuration shape, the provider lifecycle API, host-managed storage, Integration credentials, the native TUI, RPC handlers, and Agent Tools. -## OpenCode v1 legacy support +## Highlights -The OpenCode v1 adapter remains available through the combined package -entrypoint. Existing V1 configuration, provider-level discovery settings, XDG -cache, auth fallback, and helper commands remain supported for compatibility, -security fixes, and severe regressions. +### Model Metadata Enrichment -New lifecycle, cache, TUI, RPC, and model override features target OpenCode v2 -first and are not automatically backported to V1. +V2.0.0 officially documents and supports nine model info metadata formats, decoupling provider metadata ingestion from core discovery scheduling: -## V2 model overrides +- **OpenRouter (`"openrouter"`)**: Reads OpenRouter's inline `/v1/models` metadata without making a secondary request. Maps context length, top provider output limits, architecture modalities (`text`, `image`, `audio`, etc.), prompt/completion and cache pricing, tool-calling flags, and reasoning effort variants (`low`, `medium`, `high`, etc.). +- **AIProxy (`"aiproxy"`)**: Composes AIProxy inline limits, pricing, and effort tiers with models.dev display names and metadata. +- **Bifrost AI Gateway (`"bifrost"`)**: Reads inline limits, modalities, per-token pricing, and reasoning variants. +- **LiteLLM (`"litellm"`)**: Requests `/v1/model/info` to ingest rich model metadata, context limits, pricing, and capabilities. +- **LM Studio (`"lmstudio"`)**: Enriches `/v1/models` with loaded instance configurations from LM Studio 0.4.0+'s native `/api/v1/models` endpoint. +- **llama-swap (`"llama-swap"`)**: Inline discovery of context lengths, architectures, and function-calling flags. +- **models.dev (`"models.dev"`)**: Public catalog lookup for general context limits and capabilities. +- **OmniRoute (`"omniroute"`)**: Inline metadata mapping for limits, multimodal capabilities, and service-tier variants (`fast`, `flex`). +- **vLLM (`"vllm"`)**: Inline extraction of numeric `max_model_len` limits without secondary requests. + +### OpenCode v2 Integration Credentials + +OpenCode v2 providers can manage credentials interactively using OpenCode's native `/connect` workflow through the host Integration API. When configured via `/connect`, API keys do not need to be written into `opencode.json` or `opencode.jsonc`. + +### V2 Model Overrides V2 model overrides are persisted in host storage under: @@ -25,17 +32,13 @@ V2 model overrides are persisted in host storage under: overrides:: ``` -Overrides can be listed, edited, enabled, disabled, and deleted through the V2 -RPC contract and the `/models-discovery-console` model interaction. The model -list marks overridden models with `*`. Cache clearing does not delete overrides. +Overrides can be listed, edited, enabled, disabled, and deleted through the V2 RPC contract and the `/models-discovery-console` model interaction. The model list marks overridden models with `*`. Cache clearing does not delete overrides. -Supported fields include display name, limits, capabilities, reasoning, -attachments, variants, cost, and compatibility metadata. Credential and -authorization fields are rejected and are never persisted. +Supported fields include display name, limits, capabilities, reasoning, attachments, variants, cost, and compatibility metadata. Credential and authorization fields are rejected and are never persisted. ## Configuration -OpenCode v2 configuration uses: +OpenCode v2 configuration uses `opencode.jsonc` (or `opencode.json`) with the primary path: ```text providers..settings.modelsDiscovery @@ -43,16 +46,20 @@ providers..settings.modelsDiscovery Example: -> **Pre-release Note**: During RC testing, install using `"opencode-models-discovery@rc"`. - -```json +```jsonc { - "plugins": ["opencode-models-discovery@2.0.0"], + "$schema": "https://opencode.ai/config.json", + "plugins": [ + "opencode-models-discovery@2.0.0" + ], "providers": { "gateway": { "package": "@opencode-ai/ai/providers/openai-compatible", + "name": "My Gateway", "settings": { "baseURL": "https://gateway.example.com/v1", + // Optional: omit if managing credentials interactively via OpenCode '/connect' + "apiKey": "example_api_key", "modelsDiscovery": { "enabled": true, "cache": { "enabled": true, "ttlSeconds": 86400 } @@ -63,8 +70,7 @@ Example: } ``` -Use `/connect` or a host environment substitution for credentials. V2 does not -read V1 `auth.json` or `OPENCODE_AUTH_CONTENT` fallback data. +Use `/connect` or a host environment substitution (`{env:KEY}`) for credentials. V2 does not read V1 `auth.json` or `OPENCODE_AUTH_CONTENT` fallback data. ## TUI and Agent Tools @@ -77,20 +83,20 @@ models_discovery_refresh models_discovery_status ``` -The console supports model browsing, metadata inspection, cache operations, and -model override editing. Override changes reload the provider and refresh the -current TUI model list without restarting the service. +The console supports interactive model browsing, metadata inspection, cache operations, and model override editing. Override changes reload the provider and refresh the current TUI model list without restarting the service. + +## OpenCode v1 Legacy Support + +The OpenCode v1 adapter remains available through the combined package entrypoint. Existing V1 configuration, provider-level discovery settings, XDG cache, auth fallback, and helper commands remain supported for compatibility, security fixes, and severe regressions. + +New lifecycle, cache, TUI, RPC, and model override features target OpenCode v2 first and are not automatically backported to V1. ## Migration -V1 users can continue using the legacy adapter. Users moving to OpenCode v2 -should follow [Migrating Configuration from OpenCode v1 to OpenCode v2](../migrate-v1-to-v2.md). +V1 users can continue using the legacy adapter. Users moving to OpenCode v2 should follow [Migrating Configuration from OpenCode v1 to OpenCode v2](../migrate-v1-to-v2.md). -V1 XDG cache files are not imported into V2. V2 cache and override storage are -host-managed and use separate namespaces. +V1 XDG cache files are not imported into V2. V2 cache and override storage are host-managed and use separate namespaces. ## Validation -This release is validated by the repository's V1/V2 unit and contract tests, -local OpenAI-compatible HTTP integration tests, compiled package checks, and -Node package-loadability tests. +This release is validated by the repository's V1/V2 unit and contract tests (41 test files, 314 tests), local OpenAI-compatible HTTP integration tests, compiled package validation checks, and Node package-loadability dry-run tests. From 816ec29b2995737bb35b01bb6f44a53b34cdb0ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:45:50 +0000 Subject: [PATCH 10/10] chore: bump version to 2.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81540079..34cce57c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-models-discovery", - "version": "2.0.0-rc.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-models-discovery", - "version": "2.0.0-rc.2", + "version": "2.0.0", "license": "MIT", "dependencies": { "@opencode/plugin": "2.0.26", diff --git a/package.json b/package.json index b8fb5513..22c2552f 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.0-rc.2", + "version": "2.0.0", "description": "OpenCode plugin for auto-discovery of OpenAI-compatible models with dynamic provider configuration", "type": "module", "main": "./dist/index.js",