Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/v1-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
30 changes: 25 additions & 5 deletions src/plugin/enhance-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import {
discoverModels,
getLMStudioApiKey,
isGenerativeModel,
isLoadedModel,
normalizeLMStudioURL,
toOpenAICompatibleURL,
} from "../utils/lmstudio-api.ts"

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: {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})

Expand Down
5 changes: 5 additions & 0 deletions src/utils/lmstudio-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LMStudioPlugin } from "../src/index.ts"
import {
effectiveContextLength,
enhanceConfig,
resolveOnlyLoaded,
toolUseMode,
toModelConfig,
} from "../src/plugin/enhance-config.ts"
Expand Down Expand Up @@ -35,6 +36,14 @@ function model(overrides: Record<string, unknown> = {}): 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",
Expand Down Expand Up @@ -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 }))

Expand Down Expand Up @@ -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([
Expand Down