From c8f7f142189ba2757c08cfa6a11db5890f2417f9 Mon Sep 17 00:00:00 2001 From: dgnsrekt Date: Fri, 14 Aug 2026 17:53:54 -0500 Subject: [PATCH 1/2] fix: tolerate unknown LM Studio reasoning levels `capabilities.reasoning` pinned `allowed_options` and `default` to `off|on|low|medium|high`. LM Studio now publishes an `xhigh` level, and because the enum sits inside the response schema a single model carrying it fails `LMStudioModelsResponseSchema.safeParse` for the entire payload. `discoverModels` then throws `unsupported response`, so discovery yields no models at all and the provider falls back to models.dev entries the user has not downloaded. The reasoning block is parsed but never mapped: the v1 contract leaves OpenCode's `reasoning` flag unset until both projects publish an interoperable mapping. Validating these values strictly gains nothing and costs total discovery whenever LM Studio adds a level. Accept any string and keep the known levels as an exported constant for documentation, so a future level cannot break discovery the same way. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S7Wjrr4wy4Eiig1PqtHfpD --- src/types/index.ts | 11 +++++++++-- test/plugin.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index c41d9bb..d1dfb7d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,13 +9,20 @@ export const LMStudioLoadedInstanceSchema = z.looseObject({ }), }) +/** + * Reasoning levels LM Studio is known to publish. Kept as documentation only: + * the schema accepts any string so a level added by a newer LM Studio build + * cannot fail validation for the whole response. + */ +export const KNOWN_REASONING_LEVELS = ["off", "on", "low", "medium", "high"] as const + /** Capabilities reported for a native v1 LLM record. */ export const LMStudioCapabilitiesSchema = z.looseObject({ vision: z.boolean(), trained_for_tool_use: z.boolean(), reasoning: z.looseObject({ - allowed_options: z.array(z.enum(["off", "on", "low", "medium", "high"])), - default: z.enum(["off", "on", "low", "medium", "high"]), + allowed_options: z.array(z.string()), + default: z.string(), }).optional(), }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4aa1446..584fea4 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -98,6 +98,26 @@ describe("LM Studio native API v1", () => { ) }) + it("keeps discovering when a model reports a reasoning level this release predates", async () => { + const fetcher = vi.fn(async () => modelsResponse([ + model({ + capabilities: { + vision: false, + trained_for_tool_use: true, + reasoning: { allowed_options: ["off", "low", "medium", "xhigh", "on"], default: "xhigh" }, + }, + }), + model({ key: "publisher/second", display_name: "Second" }), + ])) + + const response = await discoverModels("http://127.0.0.1:1234", { + fetch: fetcher as typeof fetch, + }) + + expect(response.models).toHaveLength(2) + expect(response.models[0]?.capabilities?.reasoning?.default).toBe("xhigh") + }) + it("rejects HTTP-200 error bodies instead of treating status as endpoint support", async () => { const fetcher = vi.fn(async () => new Response(JSON.stringify({ error: "Unexpected endpoint" }), { status: 200 })) From 1c40c7a0ce61d4a9cab6067086833b8f106888b4 Mon Sep 17 00:00:00 2001 From: dgnsrekt Date: Fri, 14 Aug 2026 17:57:51 -0500 Subject: [PATCH 2/2] feat: add options.onlyLoaded to offer only loaded models Discovery offers every generative record so LM Studio can load a model on demand when it is selected. With a large library most entries are idle, and the picker fills with models that are merely downloaded (#17). On shared or remote servers, on-demand loading may not be wanted at all. `provider.lmstudio.options.onlyLoaded` restricts the generated models and whitelist to records with a non-empty `loaded_instances`, so the list matches `lms ps`. Default behavior is unchanged: the option is opt-in and only the boolean `true` enables it, so an unusable value cannot silently empty the model list. When nothing is loaded the plugin generates an empty model list rather than falling back to idle entries, and the discovery log reports `onlyLoaded` and `skippedUnloaded` so the filter is visible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S7Wjrr4wy4Eiig1PqtHfpD --- README.md | 35 +++++++++++- docs/v1-contract.md | 1 + src/plugin/enhance-config.ts | 30 +++++++++-- src/utils/lmstudio-api.ts | 5 ++ test/plugin.test.ts | 100 +++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9dec203..21785d2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ At startup, the plugin: 1. connects to the configured LM Studio server, or the documented default at `http://127.0.0.1:1234`; 2. validates `GET /api/v1/models` against the native LM Studio response shape; -3. adds `llm` records to OpenCode and excludes embedding records; +3. adds `llm` records to OpenCode and excludes embedding records, or only the + models LM Studio currently holds loaded when `options.onlyLoaded` is set; 4. maps the model key, display name, vision support, and effective context; 5. uses the active loaded context when present and the model maximum when the model is available for on-demand loading; @@ -123,6 +124,38 @@ The plugin preserves the LM Studio `key` as the model ID and uses `display_name` as the OpenCode display name. There are no model-family or model-name heuristics. +### Loaded models only — `options.onlyLoaded` + +By default every generative record is offered, loaded or not, so LM Studio can +load a model on demand when it is selected. Set `onlyLoaded` to restrict the +provider to models LM Studio currently holds in memory: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-lmstudio"], + "provider": { + "lmstudio": { + "options": { "onlyLoaded": true } + } + } +} +``` + +The model list then matches `lms ps`, and the generated whitelist shrinks with +it so unloaded models leave the picker instead of accumulating (#17). Useful +with a large library, where most entries are downloaded but idle, and on shared +or remote servers where on-demand loading is not wanted. + +Discovery runs when OpenCode starts, so the list reflects what was loaded at +that moment; load a different model and restart OpenCode to pick it up. If +nothing is loaded, the plugin generates an empty model list rather than falling +back to idle entries. The structured discovery log reports `onlyLoaded` and +`skippedUnloaded` so the filter is visible. + +Only the boolean `true` enables it; any other value keeps the default of +offering every model. + ### Context limits For an unloaded model, `max_context_length` becomes OpenCode's context limit so diff --git a/docs/v1-contract.md b/docs/v1-contract.md index 6aa9bd9..d08c79c 100644 --- a/docs/v1-contract.md +++ b/docs/v1-contract.md @@ -42,6 +42,7 @@ Automatic discovery checks only LM Studio's documented default address, | `display_name` | `name` | Use the server's display name | | `type: "llm"` | chat model | Include | | `type: "embedding"` | none | Exclude from the chat provider | +| `loaded_instances` | model map membership | Include every record by default; include only non-empty ones under `options.onlyLoaded` | | `capabilities.vision` | `attachment`, input modalities | Add image input only when true | | `capabilities.trained_for_tool_use` | `tool_call`, structured diagnostics | Keep tools enabled; report native or default handling | | `max_context_length` | `limit.context` | Use when no instance is loaded | diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index a6a0fa0..d198105 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -12,6 +12,7 @@ import { discoverModels, getLMStudioApiKey, isGenerativeModel, + isLoadedModel, normalizeLMStudioURL, toOpenAICompatibleURL, } from "../utils/lmstudio-api.ts" @@ -19,7 +20,9 @@ import { export interface EnhanceConfigResult { readonly discovered: number readonly discoveryPath: string + readonly onlyLoaded: boolean readonly skippedEmbeddings: number + readonly skippedUnloaded: number readonly skippedUnsupported: number readonly serverURL: string readonly toolUse: { @@ -41,6 +44,16 @@ function getString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } +/** + * Resolve `options.onlyLoaded`, which restricts the provider to models LM + * Studio currently holds in memory. Fails open to `false` — the documented + * default is every generative record, so an unusable value never silently + * empties the model list. + */ +export function resolveOnlyLoaded(value: unknown): boolean { + return value === true +} + export function effectiveContextLength(model: LMStudioModel): number { const loaded = model.loaded_instances.map((instance) => instance.config.context_length) return loaded.length === 0 @@ -129,8 +142,13 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger): const apiKey = existing ? getLMStudioApiKey(explicitApiKey, serverURL) : detected?.apiKey const response = detected?.response ?? await discoverModels(serverURL, { apiKey }) const generative = response.models.filter(isGenerativeModel) + // `onlyLoaded` mirrors what LM Studio is actually serving right now. + // Unloaded models stay discoverable by default so LM Studio can load them + // on demand; opting in trades that for a list with no idle entries. + const onlyLoaded = resolveOnlyLoaded(existing?.options?.onlyLoaded) + const offered = onlyLoaded ? generative.filter(isLoadedModel) : generative const discoveredModels = Object.fromEntries( - generative.map((model) => [model.key, toModelConfig(model)]), + offered.map((model) => [model.key, toModelConfig(model)]), ) const previousGenerated = generatedStates.get(config) const generatedWhitelist = previousGenerated?.whitelist !== undefined @@ -159,15 +177,17 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger): }) const result = { - discovered: generative.length, + discovered: offered.length, discoveryPath: LM_STUDIO_MODELS_PATH, + onlyLoaded, skippedEmbeddings: response.models.filter((model) => model.type === "embedding").length, + skippedUnloaded: generative.length - offered.length, skippedUnsupported: response.models.filter((model) => !isGenerativeModel(model) && model.type !== "embedding").length, serverURL, toolUse: { - default: generative.filter((model) => toolUseMode(model) === "default").map((model) => model.key), - native: generative.filter((model) => toolUseMode(model) === "native").map((model) => model.key), - unknown: generative.filter((model) => toolUseMode(model) === "unknown").map((model) => model.key), + default: offered.filter((model) => toolUseMode(model) === "default").map((model) => model.key), + native: offered.filter((model) => toolUseMode(model) === "native").map((model) => model.key), + unknown: offered.filter((model) => toolUseMode(model) === "unknown").map((model) => model.key), }, } await log("info", "Discovered LM Studio models", result) diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 8ca8104..18e8799 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -136,6 +136,11 @@ export function isGenerativeModel(model: LMStudioModel): model is LMStudioModel return model.type === "llm" } +/** Whether LM Studio currently holds at least one running instance of a model. */ +export function isLoadedModel(model: LMStudioModel): boolean { + return model.loaded_instances.length > 0 +} + export interface AutoDetectedLMStudio { readonly serverURL: string readonly apiKey?: string diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 584fea4..7ea4444 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -4,6 +4,7 @@ import { LMStudioPlugin } from "../src/index.ts" import { effectiveContextLength, enhanceConfig, + resolveOnlyLoaded, toolUseMode, toModelConfig, } from "../src/plugin/enhance-config.ts" @@ -35,6 +36,14 @@ function model(overrides: Record = {}): LMStudioModel { } as LMStudioModel } +function loaded(key: string, context = 32_768): LMStudioModel { + return model({ + key, + display_name: key, + loaded_instances: [{ id: `${key}:0`, config: { context_length: context } }], + }) +} + function embedding(key: string, loadedContext?: number): LMStudioModel { return model({ type: "embedding", @@ -271,6 +280,97 @@ describe("config enhancement", () => { ]) }) + it("offers every generative model when onlyLoaded is not configured", async () => { + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([ + loaded("publisher/running"), + model({ key: "publisher/idle", display_name: "Idle" }), + ]))) + const value = config() + + const result = await enhanceConfig(value, logger()) + + expect(result).toMatchObject({ discovered: 2, onlyLoaded: false, skippedUnloaded: 0 }) + expect(Object.keys(value.provider?.lmstudio?.models ?? {})).toEqual([ + "publisher/running", + "publisher/idle", + ]) + }) + + it("restricts models and the generated whitelist to loaded instances when onlyLoaded is set", async () => { + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([ + loaded("publisher/running", 8_192), + model({ key: "publisher/idle", display_name: "Idle" }), + model({ key: "publisher/also-idle", display_name: "Also Idle" }), + embedding("embedding/loaded", 1_024), + ]))) + const value = config({ + provider: { lmstudio: { options: { baseURL: "http://127.0.0.1:1234/v1", onlyLoaded: true } } }, + }) + const log = logger() + + const result = await enhanceConfig(value, log) + + expect(result).toMatchObject({ + discovered: 1, + onlyLoaded: true, + skippedUnloaded: 2, + skippedEmbeddings: 1, + }) + expect(Object.keys(value.provider?.lmstudio?.models ?? {})).toEqual(["publisher/running"]) + expect(value.provider?.lmstudio?.whitelist).toEqual(["publisher/running"]) + // The loaded instance's allocation still drives the context limit. + expect(value.provider?.lmstudio?.models?.["publisher/running"]?.limit?.context).toBe(8_192) + expect(log).toHaveBeenCalledWith( + "info", + "Discovered LM Studio models", + expect.objectContaining({ onlyLoaded: true, skippedUnloaded: 2 }), + ) + }) + + it("generates an empty model list rather than idle entries when onlyLoaded finds nothing loaded", async () => { + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([ + model({ key: "publisher/idle", display_name: "Idle" }), + ]))) + const value = config({ + provider: { lmstudio: { options: { baseURL: "http://127.0.0.1:1234/v1", onlyLoaded: true } } }, + }) + + const result = await enhanceConfig(value, logger()) + + expect(result).toMatchObject({ discovered: 0, onlyLoaded: true, skippedUnloaded: 1 }) + expect(value.provider?.lmstudio?.models).toEqual({}) + expect(value.provider?.lmstudio?.whitelist).toEqual([]) + }) + + it("keeps explicit model overrides while onlyLoaded filters discovered records", async () => { + vi.stubGlobal("fetch", vi.fn(async () => modelsResponse([ + loaded("publisher/running"), + model({ key: "publisher/idle", display_name: "Idle" }), + ]))) + const value = config({ + provider: { + lmstudio: { + options: { baseURL: "http://127.0.0.1:1234/v1", onlyLoaded: true }, + models: { "publisher/pinned": { name: "Pinned" } }, + }, + }, + }) + + await enhanceConfig(value, logger()) + + expect(Object.keys(value.provider?.lmstudio?.models ?? {}).sort()).toEqual([ + "publisher/pinned", + "publisher/running", + ]) + }) + + it.each([undefined, false, "true", 1, null])( + "fails open to every model when onlyLoaded is %o", + (value) => { + expect(resolveOnlyLoaded(value)).toBe(false) + }, + ) + it("replaces stale generated models and whitelist entries on a later config load", async () => { const fetcher = vi.fn() .mockResolvedValueOnce(modelsResponse([