diff --git a/scripts/capture-lmstudio-fixture.ts b/scripts/capture-lmstudio-fixture.ts index 53f1c8a..1e3cc3a 100644 --- a/scripts/capture-lmstudio-fixture.ts +++ b/scripts/capture-lmstudio-fixture.ts @@ -2,8 +2,8 @@ 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 { 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" @@ -33,20 +33,22 @@ function options(): Options { } } -function sanitize(model: LMStudioModel): LMStudioModel { +function sanitize(model: LMStudioModelRecord): LMStudioModelRecord { + if (!isGenerativeModel(model)) return { type: model.type, key: model.key } + 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, @@ -57,23 +59,28 @@ 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) +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) - 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 a6a0fa0..252b2a6 100644 --- a/src/plugin/enhance-config.ts +++ b/src/plugin/enhance-config.ts @@ -54,7 +54,7 @@ export function toolUseMode(model: LMStudioModel): ToolUseMode { return trained === true ? "native" : trained === false ? "default" : "unknown" } -export function toModelConfig(model: LMStudioModel & { type: "llm" }): 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) @@ -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 c41d9bb..5b14702 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,37 +11,81 @@ 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 +/** A validated LLM model that can be mapped into OpenCode. */ +export type LMStudioModel = z.infer +export type LMStudioNonLLMModel = z.infer +/** A record returned by LM Studio, including records excluded from chat. */ +export type LMStudioModelRecord = LMStudioModel | LMStudioNonLLMModel +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] diff --git a/src/utils/lmstudio-api.ts b/src/utils/lmstudio-api.ts index 8ca8104..1f220e4 100644 --- a/src/utils/lmstudio-api.ts +++ b/src/utils/lmstudio-api.ts @@ -1,5 +1,5 @@ import { - LMStudioModelsResponseSchema, + parseLMStudioModelsResponse, type LMStudioModel, type LMStudioModelsResponse, } from "../types/index.ts" @@ -124,15 +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) + try { + return parseLMStudioModelsResponse(payload) + } catch (cause) { + throw new LMStudioAPIError( + `LM Studio models API returned an unsupported response: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + ) } - - return result.data } -export function isGenerativeModel(model: LMStudioModel): model is LMStudioModel & { type: "llm" } { +export function isGenerativeModel(model: LMStudioModelsResponse["models"][number]): model is LMStudioModel { return model.type === "llm" } @@ -143,13 +145,20 @@ export interface AutoDetectedLMStudio { } /** Connect to LM Studio's documented default local endpoint when it validates. */ -export async function autoDetectLMStudio(): Promise { +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 { + } catch (error) { + options.onError?.(error) // Continue until an endpoint returns valid LM Studio metadata. } } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4aa1446..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, 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, @@ -35,18 +41,8 @@ function model(overrides: Record = {}): LMStudioModel { } as LMStudioModel } -function embedding(key: string, loadedContext?: number): LMStudioModel { - 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, - }) +function embedding(key: string): LMStudioModelRecord { + return { type: "embedding", key } } function modelsResponse(models: Array | LMStudioModel>, status = 200) { @@ -72,6 +68,58 @@ afterEach(() => { }) describe("LM Studio native API v1", () => { + it("parses current LLM metadata and embedding records", async () => { + const fetcher = vi.fn(async () => modelsResponse([ + { + type: "llm", + key: "prism-ml/bonsai-27b", + display_name: "Bonsai 27B", + size_bytes: 8_521_076_756, + loaded_instances: [], + max_context_length: 262_144, + format: "mlx", + capabilities: { + vision: true, + reasoning: { allowed_options: ["off", "on"], default: "on" }, + }, + unknown_metadata: { added_by: "a newer LM Studio version" }, + }, + { + 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, + parallel: 4, + }, + }], + max_context_length: 262_144, + format: "gguf", + capabilities: { + vision: true, + reasoning: { allowed_options: ["off", "low", "medium", "xhigh", "on"], default: "xhigh" }, + }, + }, + { 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", + "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 +154,30 @@ 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(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) => { const fetcher = vi.fn(async () => modelsResponse([], status)) @@ -162,7 +234,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 +249,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", @@ -207,7 +279,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() @@ -339,6 +411,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", () => {