From c83fbbe118f669cd881250ad6f4e6a0f15082b3b Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 21:47:37 -0500 Subject: [PATCH 1/6] fix: support current LM Studio model metadata --- scripts/capture-lmstudio-fixture.ts | 14 ++-- src/plugin/enhance-config.ts | 8 +- src/types/index.ts | 48 ++++++++---- src/utils/lmstudio-api.ts | 37 +++++++++- test/plugin.test.ts | 110 ++++++++++++++++++++++++++-- 5 files changed, 184 insertions(+), 33 deletions(-) diff --git a/scripts/capture-lmstudio-fixture.ts b/scripts/capture-lmstudio-fixture.ts index 53f1c8a..42824cc 100644 --- a/scripts/capture-lmstudio-fixture.ts +++ b/scripts/capture-lmstudio-fixture.ts @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" import { LMStudioModelsResponseSchema, type LMStudioModel } from "../src/types/index.ts" -import { DEFAULT_LM_STUDIO_URL, discoverModels, getLMStudioApiKey } from "../src/utils/lmstudio-api.ts" +import { DEFAULT_LM_STUDIO_URL, discoverModels, getLMStudioApiKey, isGenerativeModel } from "../src/utils/lmstudio-api.ts" const DEFAULT_OUTPUT = "test/fixtures/lmstudio-models.json" @@ -34,19 +34,21 @@ function options(): Options { } function sanitize(model: LMStudioModel): LMStudioModel { + if (!isGenerativeModel(model)) return model + return { type: model.type, key: model.key, display_name: model.display_name, - publisher: model.publisher, + ...(model.publisher !== undefined ? { publisher: model.publisher } : {}), ...(model.architecture !== undefined ? { architecture: model.architecture } : {}), - quantization: model.quantization, + ...(model.quantization !== undefined ? { quantization: model.quantization } : {}), loaded_instances: model.loaded_instances.map((instance) => ({ id: instance.id, config: { context_length: instance.config.context_length }, })), max_context_length: model.max_context_length, - format: model.format, + ...(model.format !== undefined ? { format: model.format } : {}), ...(model.capabilities ? { capabilities: { vision: model.capabilities.vision, @@ -58,8 +60,8 @@ function sanitize(model: LMStudioModel): LMStudioModel { } function selectRepresentative(models: LMStudioModel[]): LMStudioModel[] { - const text = models.find((model) => model.type === "llm" && model.capabilities?.vision !== true) - const vision = models.find((model) => model.type === "llm" && model.capabilities?.vision === true) + const text = models.find((model) => isGenerativeModel(model) && model.capabilities?.vision !== true) + const vision = models.find((model) => isGenerativeModel(model) && model.capabilities?.vision === true) const embedding = models.find((model) => model.type === "embedding") const selected = [text, vision, embedding].filter((model): model is LMStudioModel => model !== undefined) if (selected.length !== 3) { diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index a6a0fa0..13183ae 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -1,5 +1,5 @@ import type { - LMStudioModel, + LMStudioLLMModel, ModelConfig, OpenCodeConfig, PluginLogger, @@ -41,7 +41,7 @@ function getString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } -export function effectiveContextLength(model: LMStudioModel): number { +export function effectiveContextLength(model: LMStudioLLMModel): number { const loaded = model.loaded_instances.map((instance) => instance.config.context_length) return loaded.length === 0 ? model.max_context_length @@ -49,12 +49,12 @@ export function effectiveContextLength(model: LMStudioModel): number { } /** Classify LM Studio's native-vs-default tool handling without disabling tools. */ -export function toolUseMode(model: LMStudioModel): ToolUseMode { +export function toolUseMode(model: LMStudioLLMModel): ToolUseMode { const trained = model.capabilities?.trained_for_tool_use return trained === true ? "native" : trained === false ? "default" : "unknown" } -export function toModelConfig(model: LMStudioModel & { type: "llm" }): ModelConfig { +export function toModelConfig(model: LMStudioLLMModel): ModelConfig { const vision = model.capabilities?.vision === true const input: Array<"text" | "image"> = vision ? ["text", "image"] : ["text"] const context = effectiveContextLength(model) diff --git a/src/types/index.ts b/src/types/index.ts index c41d9bb..c6f5a54 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,37 +11,59 @@ export const LMStudioLoadedInstanceSchema = z.looseObject({ /** Capabilities reported for a native v1 LLM record. */ export const LMStudioCapabilitiesSchema = z.looseObject({ - vision: z.boolean(), - trained_for_tool_use: z.boolean(), + vision: z.boolean().optional(), + trained_for_tool_use: z.boolean().optional(), reasoning: z.looseObject({ - allowed_options: z.array(z.enum(["off", "on", "low", "medium", "high"])), - default: z.enum(["off", "on", "low", "medium", "high"]), + // LM Studio can add reasoning modes without a plugin release. + allowed_options: z.array(z.string()).optional(), + default: z.string().optional(), }).optional(), }) -/** LM Studio's documented native `GET /api/v1/models` model record. */ -export const LMStudioModelSchema = z.looseObject({ - type: z.string().min(1), +/** Fields required to map an LLM into an OpenCode provider model. */ +export const LMStudioLLMModelSchema = z.looseObject({ + type: z.literal("llm"), key: z.string().min(1), display_name: z.string().min(1), - publisher: z.string().min(1), + publisher: z.string().min(1).optional(), architecture: z.string().nullable().optional(), quantization: z.looseObject({ name: z.string().nullable(), bits_per_weight: z.number().nullable(), - }).nullable(), + }).nullable().optional(), loaded_instances: z.array(LMStudioLoadedInstanceSchema), max_context_length: z.number().int().positive(), - format: z.enum(["gguf", "mlx"]).nullable(), + format: z.string().nullable().optional(), capabilities: LMStudioCapabilitiesSchema.optional(), }) +/** Non-LLM records are retained only for filtering and diagnostics. */ +export const LMStudioNonLLMModelSchema = z.looseObject({ + type: z.string().min(1).refine((type) => type !== "llm"), + key: z.string().min(1), +}) + +/** LM Studio's native `GET /api/v1/models` model record. */ +export const LMStudioModelSchema = z.union([ + LMStudioLLMModelSchema, + LMStudioNonLLMModelSchema, +]) + export const LMStudioModelsResponseSchema = z.looseObject({ - models: z.array(LMStudioModelSchema), + // Individual records are validated below so one non-LLM shape cannot + // impose LLM-only fields on the entire native response. + models: z.array(z.any()), }) -export type LMStudioModel = z.infer -export type LMStudioModelsResponse = z.infer +export type LMStudioLLMModel = z.infer +export type LMStudioNonLLMModel = { + type: string + key: string +} +export type LMStudioModel = LMStudioLLMModel | LMStudioNonLLMModel +export type LMStudioModelsResponse = { + models: LMStudioModel[] +} export type OpenCodeConfig = Parameters>[0] export type ProviderConfig = NonNullable[string] diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 8ca8104..bea76f5 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,5 +1,7 @@ import { + LMStudioModelSchema, LMStudioModelsResponseSchema, + type LMStudioLLMModel, type LMStudioModel, type LMStudioModelsResponse, } from "../types/index.ts" @@ -126,13 +128,37 @@ export async function discoverModels( const result = LMStudioModelsResponseSchema.safeParse(payload) if (!result.success) { - throw new LMStudioAPIError("LM Studio models API returned an unsupported response", result.error) + throw new LMStudioAPIError( + `LM Studio models API returned an unsupported response: ${result.error.message}`, + result.error, + ) } - return result.data + const models: LMStudioModel[] = [] + const invalidModels: string[] = [] + for (const [index, candidate] of result.data.models.entries()) { + const model = LMStudioModelSchema.safeParse(candidate) + if (model.success) { + models.push(model.data) + continue + } + + const key = typeof candidate === "object" && candidate !== null && "key" in candidate + ? String(candidate.key) + : `index ${index}` + invalidModels.push(`${key}: ${model.error.message}`) + } + + if (invalidModels.length > 0) { + throw new LMStudioAPIError( + `LM Studio models API returned invalid model records: ${invalidModels.join("; ")}`, + ) + } + + return { ...result.data, models } } -export function isGenerativeModel(model: LMStudioModel): model is LMStudioModel & { type: "llm" } { +export function isGenerativeModel(model: LMStudioModel): model is LMStudioLLMModel { return model.type === "llm" } @@ -144,14 +170,17 @@ export interface AutoDetectedLMStudio { /** Connect to LM Studio's documented default local endpoint when it validates. */ export async function autoDetectLMStudio(): Promise { + let lastError: unknown for (const serverURL of AUTO_DETECT_URLS) { const apiKey = getLMStudioApiKey(undefined, serverURL) try { const response = await discoverModels(serverURL, { apiKey, timeoutMs: 1_000 }) return { serverURL, apiKey, response } - } catch { + } catch (error) { + lastError = error // Continue until an endpoint returns valid LM Studio metadata. } } + if (lastError !== undefined) throw lastError return undefined } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4aa1446..a8ccc90 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -7,7 +7,7 @@ import { toolUseMode, toModelConfig, } from "../src/plugin/enhance-config.ts" -import type { LMStudioModel, OpenCodeConfig, PluginLogger } from "../src/types/index.ts" +import type { LMStudioLLMModel, LMStudioModel, OpenCodeConfig, PluginLogger } from "../src/types/index.ts" import { LMStudioAPIError, autoDetectLMStudio, @@ -19,7 +19,7 @@ import { toOpenAICompatibleURL, } from "../src/utils/lmstudio-api.ts" -function model(overrides: Record = {}): LMStudioModel { +function model(overrides: Record = {}): LMStudioLLMModel { return { type: "llm", key: "publisher/model", @@ -32,7 +32,7 @@ function model(overrides: Record = {}): LMStudioModel { format: "gguf", capabilities: { vision: false, trained_for_tool_use: true }, ...overrides, - } as LMStudioModel + } as LMStudioLLMModel } function embedding(key: string, loadedContext?: number): LMStudioModel { @@ -46,7 +46,7 @@ function embedding(key: string, loadedContext?: number): LMStudioModel { ? [{ id: `${key}:loaded`, config: { context_length: loadedContext } }] : [], max_context_length: 2_048, - }) + }) as unknown as LMStudioModel } function modelsResponse(models: Array | LMStudioModel>, status = 200) { @@ -72,6 +72,82 @@ afterEach(() => { }) describe("LM Studio native API v1", () => { + it("discovers current LLM records with loose metadata and skips embeddings", async () => { + const fetcher = vi.fn(async () => modelsResponse([ + { + type: "llm", + publisher: "prism-ml", + key: "prism-ml/bonsai-27b", + display_name: "Bonsai 27B", + architecture: "qwen3_5", + quantization: { name: "2bit", bits_per_weight: 2 }, + size_bytes: 8_521_076_756, + params_string: "27B", + loaded_instances: [], + max_context_length: 262_144, + format: "mlx", + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: { allowed_options: ["off", "on"], default: "on" }, + }, + variants: ["prism-ml/bonsai-27b@2bit"], + }, + { + type: "llm", + key: "qwen/qwen3.8-27b", + display_name: "Qwen3.8 27B", + loaded_instances: [{ + id: "qwen-loaded", + config: { + context_length: 131_072, + eval_batch_size: 2_048, + physical_batch_size: 512, + parallel: 4, + flash_attention: true, + context_checkpoints: 32, + speculative_draft_model: "", + }, + }], + max_context_length: 262_144, + format: "gguf", + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: { allowed_options: ["off", "low", "medium", "xhigh", "on"], default: "xhigh" }, + }, + }, + { + type: "llm", + key: "google/gemma-4-e4b", + display_name: "Gemma 4 E4B", + loaded_instances: [], + max_context_length: 131_072, + format: "gguf", + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: { allowed_options: ["off", "on"], default: "on" }, + }, + }, + { type: "embedding", key: "text-embedding-nomic-embed-text-v1.5" }, + ])) + + const response = await discoverModels("http://127.0.0.1:1234", { + fetch: fetcher as typeof fetch, + }) + + expect(response.models.map((entry) => entry.key)).toEqual([ + "prism-ml/bonsai-27b", + "qwen/qwen3.8-27b", + "google/gemma-4-e4b", + "text-embedding-nomic-embed-text-v1.5", + ]) + expect(response.models[1]).toMatchObject({ + loaded_instances: [{ config: { context_length: 131_072 } }], + }) + }) + it("normalizes provider URLs onto the documented native and compatible endpoints", () => { expect(normalizeLMStudioURL("http://127.0.0.1:1234/v1/")).toBe("http://127.0.0.1:1234") expect(toOpenAICompatibleURL("https://models.example.test/v1")).toBe("https://models.example.test/v1") @@ -106,6 +182,14 @@ describe("LM Studio native API v1", () => { })).rejects.toThrow("unsupported response") }) + it("includes the validation reason when the response envelope is invalid", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ models: "not-an-array" }), { status: 200 })) + + await expect(discoverModels("http://127.0.0.1:1234", { + fetch: fetcher as typeof fetch, + })).rejects.toThrow(/expected.*array/) + }) + it.each([401, 403])("does not retry HTTP %i authentication failures against an older endpoint", async (status) => { const fetcher = vi.fn(async () => modelsResponse([], status)) @@ -162,7 +246,7 @@ describe("model mapping", () => { expect(effectiveContextLength(unloaded)).toBe(131_072) expect(effectiveContextLength(single)).toBe(65_536) expect(effectiveContextLength(multiple)).toBe(16_384) - expect(toModelConfig(multiple as LMStudioModel & { type: "llm" }).limit).toEqual({ + expect(toModelConfig(multiple).limit).toEqual({ context: 16_384, output: 4_096, }) @@ -177,7 +261,7 @@ describe("model mapping", () => { trained_for_tool_use: false, reasoning: { allowed_options: ["off", "on"], default: "on" }, }, - }) as LMStudioModel & { type: "llm" }) + })) expect(mapped).toMatchObject({ id: "zai-org/glm-4.5v", @@ -339,6 +423,20 @@ describe("config enhancement", () => { expect(value).toEqual({}) expect(log).toHaveBeenCalledWith("debug", "LM Studio model discovery unavailable", expect.any(Object)) }) + + it("logs the native response validation reason during auto-detection", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ models: "invalid" }), { status: 200 }))) + const value = config() + const log = logger() + + await expect(enhanceConfig(value, log)).resolves.toBeUndefined() + + expect(log).toHaveBeenCalledWith( + "debug", + "LM Studio model discovery unavailable", + expect.objectContaining({ error: expect.stringContaining("expected array") }), + ) + }) }) describe("plugin entrypoint", () => { From c174005b323fbccaceb0f683d0a074116c5c370a Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 21:55:22 -0500 Subject: [PATCH 2/6] refactor: derive native model types from schemas --- src/types/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index c6f5a54..c20686a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,10 +56,7 @@ export const LMStudioModelsResponseSchema = z.looseObject({ }) export type LMStudioLLMModel = z.infer -export type LMStudioNonLLMModel = { - type: string - key: string -} +export type LMStudioNonLLMModel = z.infer export type LMStudioModel = LMStudioLLMModel | LMStudioNonLLMModel export type LMStudioModelsResponse = { models: LMStudioModel[] From e8b4ea9c97ba1ad3324c29eb06790f549412f4c9 Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 21:57:19 -0500 Subject: [PATCH 3/6] refactor: preserve LMStudioModel public type --- scripts/capture-lmstudio-fixture.ts | 10 +++++----- src/plugin/enhance-config.ts | 8 ++++---- src/types/index.ts | 8 +++++--- src/utils/lmstudio-api.ts | 6 +++--- test/plugin.test.ts | 10 +++++----- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/scripts/capture-lmstudio-fixture.ts b/scripts/capture-lmstudio-fixture.ts index 42824cc..d731317 100644 --- a/scripts/capture-lmstudio-fixture.ts +++ b/scripts/capture-lmstudio-fixture.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" -import { LMStudioModelsResponseSchema, type LMStudioModel } from "../src/types/index.ts" +import { LMStudioModelsResponseSchema, type LMStudioModelRecord } from "../src/types/index.ts" import { DEFAULT_LM_STUDIO_URL, discoverModels, getLMStudioApiKey, isGenerativeModel } from "../src/utils/lmstudio-api.ts" const DEFAULT_OUTPUT = "test/fixtures/lmstudio-models.json" @@ -33,7 +33,7 @@ function options(): Options { } } -function sanitize(model: LMStudioModel): LMStudioModel { +function sanitize(model: LMStudioModelRecord): LMStudioModelRecord { if (!isGenerativeModel(model)) return model return { @@ -59,18 +59,18 @@ function sanitize(model: LMStudioModel): LMStudioModel { } } -function selectRepresentative(models: LMStudioModel[]): LMStudioModel[] { +function selectRepresentative(models: LMStudioModelRecord[]): LMStudioModelRecord[] { const text = models.find((model) => isGenerativeModel(model) && model.capabilities?.vision !== true) const vision = models.find((model) => isGenerativeModel(model) && model.capabilities?.vision === true) const embedding = models.find((model) => model.type === "embedding") - const selected = [text, vision, embedding].filter((model): model is LMStudioModel => model !== undefined) + const selected = [text, vision, embedding].filter((model): model is LMStudioModelRecord => model !== undefined) if (selected.length !== 3) { throw new Error("LM Studio must provide representative text, vision, and embedding models") } return selected } -async function readModels(config: Options): Promise { +async function readModels(config: Options): Promise { if (config.input) { const raw: unknown = JSON.parse(readFileSync(config.input, "utf8")) const parsed = LMStudioModelsResponseSchema.safeParse(raw) diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 13183ae..9abed30 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -1,5 +1,5 @@ import type { - LMStudioLLMModel, + LMStudioModel, ModelConfig, OpenCodeConfig, PluginLogger, @@ -41,7 +41,7 @@ function getString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } -export function effectiveContextLength(model: LMStudioLLMModel): number { +export function effectiveContextLength(model: LMStudioModel): number { const loaded = model.loaded_instances.map((instance) => instance.config.context_length) return loaded.length === 0 ? model.max_context_length @@ -49,12 +49,12 @@ export function effectiveContextLength(model: LMStudioLLMModel): number { } /** Classify LM Studio's native-vs-default tool handling without disabling tools. */ -export function toolUseMode(model: LMStudioLLMModel): ToolUseMode { +export function toolUseMode(model: LMStudioModel): ToolUseMode { const trained = model.capabilities?.trained_for_tool_use return trained === true ? "native" : trained === false ? "default" : "unknown" } -export function toModelConfig(model: LMStudioLLMModel): ModelConfig { +export function toModelConfig(model: LMStudioModel): ModelConfig { const vision = model.capabilities?.vision === true const input: Array<"text" | "image"> = vision ? ["text", "image"] : ["text"] const context = effectiveContextLength(model) diff --git a/src/types/index.ts b/src/types/index.ts index c20686a..1adfcb0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,11 +55,13 @@ export const LMStudioModelsResponseSchema = z.looseObject({ models: z.array(z.any()), }) -export type LMStudioLLMModel = z.infer +/** A validated LLM model that can be mapped into OpenCode. */ +export type LMStudioModel = z.infer export type LMStudioNonLLMModel = z.infer -export type LMStudioModel = LMStudioLLMModel | LMStudioNonLLMModel +/** A record returned by LM Studio, including records excluded from chat. */ +export type LMStudioModelRecord = LMStudioModel | LMStudioNonLLMModel export type LMStudioModelsResponse = { - models: LMStudioModel[] + models: LMStudioModelRecord[] } export type OpenCodeConfig = Parameters>[0] diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index bea76f5..7e99dc8 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,8 +1,8 @@ import { LMStudioModelSchema, LMStudioModelsResponseSchema, - type LMStudioLLMModel, type LMStudioModel, + type LMStudioModelRecord, type LMStudioModelsResponse, } from "../types/index.ts" import { isIP } from "node:net" @@ -134,7 +134,7 @@ export async function discoverModels( ) } - const models: LMStudioModel[] = [] + const models: LMStudioModelRecord[] = [] const invalidModels: string[] = [] for (const [index, candidate] of result.data.models.entries()) { const model = LMStudioModelSchema.safeParse(candidate) @@ -158,7 +158,7 @@ export async function discoverModels( return { ...result.data, models } } -export function isGenerativeModel(model: LMStudioModel): model is LMStudioLLMModel { +export function isGenerativeModel(model: LMStudioModelRecord): model is LMStudioModel { return model.type === "llm" } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index a8ccc90..839eec6 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -7,7 +7,7 @@ import { toolUseMode, toModelConfig, } from "../src/plugin/enhance-config.ts" -import type { LMStudioLLMModel, LMStudioModel, OpenCodeConfig, PluginLogger } from "../src/types/index.ts" +import type { LMStudioModel, LMStudioModelRecord, OpenCodeConfig, PluginLogger } from "../src/types/index.ts" import { LMStudioAPIError, autoDetectLMStudio, @@ -19,7 +19,7 @@ import { toOpenAICompatibleURL, } from "../src/utils/lmstudio-api.ts" -function model(overrides: Record = {}): LMStudioLLMModel { +function model(overrides: Record = {}): LMStudioModel { return { type: "llm", key: "publisher/model", @@ -32,10 +32,10 @@ function model(overrides: Record = {}): LMStudioLLMModel { format: "gguf", capabilities: { vision: false, trained_for_tool_use: true }, ...overrides, - } as LMStudioLLMModel + } as LMStudioModel } -function embedding(key: string, loadedContext?: number): LMStudioModel { +function embedding(key: string, loadedContext?: number): LMStudioModelRecord { return model({ type: "embedding", key, @@ -46,7 +46,7 @@ function embedding(key: string, loadedContext?: number): LMStudioModel { ? [{ id: `${key}:loaded`, config: { context_length: loadedContext } }] : [], max_context_length: 2_048, - }) as unknown as LMStudioModel + }) as unknown as LMStudioModelRecord } function modelsResponse(models: Array | LMStudioModel>, status = 200) { From 8e560869b8f728eda2c1f91c96bb14983e899930 Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 21:58:38 -0500 Subject: [PATCH 4/6] test: remove unnecessary model cast --- test/plugin.test.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 839eec6..7f5d20e 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -35,18 +35,8 @@ function model(overrides: Record = {}): LMStudioModel { } as LMStudioModel } -function embedding(key: string, loadedContext?: number): LMStudioModelRecord { - return model({ - type: "embedding", - key, - display_name: key, - architecture: null, - capabilities: undefined, - loaded_instances: loadedContext - ? [{ id: `${key}:loaded`, config: { context_length: loadedContext } }] - : [], - max_context_length: 2_048, - }) as unknown as LMStudioModelRecord +function embedding(key: string): LMStudioModelRecord { + return { type: "embedding", key } } function modelsResponse(models: Array | LMStudioModel>, status = 200) { @@ -291,7 +281,7 @@ describe("config enhancement", () => { capabilities: { vision: true, trained_for_tool_use: true }, }), embedding("embedding/unloaded"), - embedding("embedding/loaded", 1_024), + embedding("embedding/loaded"), model({ type: "future-domain", key: "future/model", display_name: "Future Model" }), ]))) const value = config() From ccdeb5ccb782a718a56d358d2e9702533229280a Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 21:59:24 -0500 Subject: [PATCH 5/6] test: trim native metadata fixture --- test/plugin.test.ts | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7f5d20e..f9c1c26 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -62,26 +62,21 @@ afterEach(() => { }) describe("LM Studio native API v1", () => { - it("discovers current LLM records with loose metadata and skips embeddings", async () => { + it("parses current LLM metadata and embedding records", async () => { const fetcher = vi.fn(async () => modelsResponse([ { type: "llm", - publisher: "prism-ml", key: "prism-ml/bonsai-27b", display_name: "Bonsai 27B", - architecture: "qwen3_5", - quantization: { name: "2bit", bits_per_weight: 2 }, size_bytes: 8_521_076_756, - params_string: "27B", loaded_instances: [], max_context_length: 262_144, format: "mlx", capabilities: { vision: true, - trained_for_tool_use: true, reasoning: { allowed_options: ["off", "on"], default: "on" }, }, - variants: ["prism-ml/bonsai-27b@2bit"], + unknown_metadata: { added_by: "a newer LM Studio version" }, }, { type: "llm", @@ -92,34 +87,16 @@ describe("LM Studio native API v1", () => { config: { context_length: 131_072, eval_batch_size: 2_048, - physical_batch_size: 512, parallel: 4, - flash_attention: true, - context_checkpoints: 32, - speculative_draft_model: "", }, }], max_context_length: 262_144, format: "gguf", capabilities: { vision: true, - trained_for_tool_use: true, reasoning: { allowed_options: ["off", "low", "medium", "xhigh", "on"], default: "xhigh" }, }, }, - { - type: "llm", - key: "google/gemma-4-e4b", - display_name: "Gemma 4 E4B", - loaded_instances: [], - max_context_length: 131_072, - format: "gguf", - capabilities: { - vision: true, - trained_for_tool_use: true, - reasoning: { allowed_options: ["off", "on"], default: "on" }, - }, - }, { type: "embedding", key: "text-embedding-nomic-embed-text-v1.5" }, ])) @@ -130,7 +107,6 @@ describe("LM Studio native API v1", () => { expect(response.models.map((entry) => entry.key)).toEqual([ "prism-ml/bonsai-27b", "qwen/qwen3.8-27b", - "google/gemma-4-e4b", "text-embedding-nomic-embed-text-v1.5", ]) expect(response.models[1]).toMatchObject({ From 41cc0b5c66fa1f9c3a8b051fd884094d56987fe5 Mon Sep 17 00:00:00 2001 From: Joe Mizzi Date: Tue, 8 Sep 2026 22:07:32 -0500 Subject: [PATCH 6/6] fix: harden discovery validation diagnostics --- scripts/capture-lmstudio-fixture.ts | 15 ++++++--- src/plugin/enhance-config.ts | 8 ++++- src/types/index.ts | 23 +++++++++++++ src/utils/lmstudio-api.ts | 50 +++++++++-------------------- test/plugin.test.ts | 26 +++++++++++++-- 5 files changed, 79 insertions(+), 43 deletions(-) diff --git a/scripts/capture-lmstudio-fixture.ts b/scripts/capture-lmstudio-fixture.ts index d731317..1e3cc3a 100644 --- a/scripts/capture-lmstudio-fixture.ts +++ b/scripts/capture-lmstudio-fixture.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" -import { LMStudioModelsResponseSchema, type LMStudioModelRecord } from "../src/types/index.ts" +import { parseLMStudioModelsResponse, type LMStudioModelRecord } from "../src/types/index.ts" import { DEFAULT_LM_STUDIO_URL, discoverModels, getLMStudioApiKey, isGenerativeModel } from "../src/utils/lmstudio-api.ts" const DEFAULT_OUTPUT = "test/fixtures/lmstudio-models.json" @@ -34,7 +34,7 @@ function options(): Options { } function sanitize(model: LMStudioModelRecord): LMStudioModelRecord { - if (!isGenerativeModel(model)) return model + if (!isGenerativeModel(model)) return { type: model.type, key: model.key } return { type: model.type, @@ -73,9 +73,14 @@ function selectRepresentative(models: LMStudioModelRecord[]): LMStudioModelRecor async function readModels(config: Options): Promise { if (config.input) { const raw: unknown = JSON.parse(readFileSync(config.input, "utf8")) - const parsed = LMStudioModelsResponseSchema.safeParse(raw) - if (!parsed.success) throw new Error(`LM Studio fixture input failed validation: ${parsed.error.message}`) - return parsed.data.models + try { + return parseLMStudioModelsResponse(raw).models + } catch (error) { + throw new Error( + `LM Studio fixture input failed validation: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } } const apiKey = getLMStudioApiKey(undefined, config.serverURL) return (await discoverModels(config.serverURL, { apiKey, timeoutMs: 10_000 })).models diff --git a/src/plugin/enhance-config.ts b/src/plugin/enhance-config.ts index 9abed30..252b2a6 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -116,11 +116,17 @@ export async function enhanceConfig(config: OpenCodeConfig, log: PluginLogger): try { const explicitApiKey = getString(existing?.options?.apiKey) - const detected = existing ? undefined : await autoDetectLMStudio() + let detectionError: unknown + const detected = existing + ? undefined + : await autoDetectLMStudio({ onError: (error) => { detectionError = error } }) if (!existing && !detected) { await log("debug", "LM Studio model discovery unavailable", { discoveryPath: LM_STUDIO_MODELS_PATH, serverURL: DEFAULT_LM_STUDIO_URL, + ...(detectionError !== undefined + ? { error: detectionError instanceof Error ? detectionError.message : String(detectionError) } + : {}), }) return undefined } diff --git a/src/types/index.ts b/src/types/index.ts index 1adfcb0..5b14702 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -64,6 +64,29 @@ export type LMStudioModelsResponse = { models: LMStudioModelRecord[] } +/** Parse the response envelope and validate every native model record. */ +export function parseLMStudioModelsResponse(payload: unknown): LMStudioModelsResponse { + const envelope = LMStudioModelsResponseSchema.safeParse(payload) + if (!envelope.success) throw envelope.error + + const models: LMStudioModelRecord[] = [] + const issues: z.core.$ZodIssue[] = [] + for (const [index, candidate] of envelope.data.models.entries()) { + const model = LMStudioModelSchema.safeParse(candidate) + if (model.success) { + models.push(model.data) + continue + } + issues.push(...model.error.issues.map((issue) => ({ + ...issue, + path: ["models", index, ...issue.path], + }))) + } + + if (issues.length > 0) throw new z.ZodError(issues) + return { models } +} + export type OpenCodeConfig = Parameters>[0] export type ProviderConfig = NonNullable[string] export type ModelConfig = NonNullable[string] diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 7e99dc8..1f220e4 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,8 +1,6 @@ import { - LMStudioModelSchema, - LMStudioModelsResponseSchema, + parseLMStudioModelsResponse, type LMStudioModel, - type LMStudioModelRecord, type LMStudioModelsResponse, } from "../types/index.ts" import { isIP } from "node:net" @@ -126,39 +124,17 @@ export async function discoverModels( throw new LMStudioAPIError("LM Studio models API returned invalid JSON", cause) } - const result = LMStudioModelsResponseSchema.safeParse(payload) - if (!result.success) { - throw new LMStudioAPIError( - `LM Studio models API returned an unsupported response: ${result.error.message}`, - result.error, - ) - } - - const models: LMStudioModelRecord[] = [] - const invalidModels: string[] = [] - for (const [index, candidate] of result.data.models.entries()) { - const model = LMStudioModelSchema.safeParse(candidate) - if (model.success) { - models.push(model.data) - continue - } - - const key = typeof candidate === "object" && candidate !== null && "key" in candidate - ? String(candidate.key) - : `index ${index}` - invalidModels.push(`${key}: ${model.error.message}`) - } - - if (invalidModels.length > 0) { + try { + return parseLMStudioModelsResponse(payload) + } catch (cause) { throw new LMStudioAPIError( - `LM Studio models API returned invalid model records: ${invalidModels.join("; ")}`, + `LM Studio models API returned an unsupported response: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, ) } - - return { ...result.data, models } } -export function isGenerativeModel(model: LMStudioModelRecord): model is LMStudioModel { +export function isGenerativeModel(model: LMStudioModelsResponse["models"][number]): model is LMStudioModel { return model.type === "llm" } @@ -169,18 +145,22 @@ export interface AutoDetectedLMStudio { } /** Connect to LM Studio's documented default local endpoint when it validates. */ -export async function autoDetectLMStudio(): Promise { - let lastError: unknown +export interface AutoDetectOptions { + readonly onError?: (error: unknown) => void +} + +export async function autoDetectLMStudio( + options: AutoDetectOptions = {}, +): Promise { for (const serverURL of AUTO_DETECT_URLS) { const apiKey = getLMStudioApiKey(undefined, serverURL) try { const response = await discoverModels(serverURL, { apiKey, timeoutMs: 1_000 }) return { serverURL, apiKey, response } } catch (error) { - lastError = error + options.onError?.(error) // Continue until an endpoint returns valid LM Studio metadata. } } - if (lastError !== undefined) throw lastError return undefined } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index f9c1c26..10a7c48 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -7,7 +7,13 @@ import { toolUseMode, toModelConfig, } from "../src/plugin/enhance-config.ts" -import type { LMStudioModel, LMStudioModelRecord, OpenCodeConfig, PluginLogger } from "../src/types/index.ts" +import { + parseLMStudioModelsResponse, + type LMStudioModel, + type LMStudioModelRecord, + type OpenCodeConfig, + type PluginLogger, +} from "../src/types/index.ts" import { LMStudioAPIError, autoDetectLMStudio, @@ -153,7 +159,23 @@ describe("LM Studio native API v1", () => { await expect(discoverModels("http://127.0.0.1:1234", { fetch: fetcher as typeof fetch, - })).rejects.toThrow(/expected.*array/) + })).rejects.toThrow(LMStudioAPIError) + }) + + it("reports the record path when an individual model is malformed", () => { + expect(() => parseLMStudioModelsResponse({ + models: [{ type: "llm", key: "broken/model" }], + })).toThrow(/models/) + + expect(() => parseLMStudioModelsResponse({ models: [null] })).toThrow(/models/) + }) + + it("preserves auto-detection's undefined result while reporting probe failures", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("offline") })) + const onError = vi.fn() + + await expect(autoDetectLMStudio({ onError })).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledWith(expect.any(Error)) }) it.each([401, 403])("does not retry HTTP %i authentication failures against an older endpoint", async (status) => {