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/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/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 4aa1446..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", @@ -98,6 +107,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 })) @@ -251,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([