Skip to content
Closed
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: 21 additions & 14 deletions scripts/capture-lmstudio-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand All @@ -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<LMStudioModel[]> {
async function readModels(config: Options): Promise<LMStudioModelRecord[]> {
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
Expand Down
10 changes: 8 additions & 2 deletions src/plugin/enhance-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
70 changes: 57 additions & 13 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof LMStudioModelSchema>
export type LMStudioModelsResponse = z.infer<typeof LMStudioModelsResponseSchema>
/** A validated LLM model that can be mapped into OpenCode. */
export type LMStudioModel = z.infer<typeof LMStudioLLMModelSchema>
export type LMStudioNonLLMModel = z.infer<typeof LMStudioNonLLMModelSchema>
/** 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<NonNullable<Hooks["config"]>>[0]
export type ProviderConfig = NonNullable<OpenCodeConfig["provider"]>[string]
Expand Down
27 changes: 18 additions & 9 deletions src/utils/lmstudio-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
LMStudioModelsResponseSchema,
parseLMStudioModelsResponse,
type LMStudioModel,
type LMStudioModelsResponse,
} from "../types/index.ts"
Expand Down Expand Up @@ -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"
}

Expand All @@ -143,13 +145,20 @@ export interface AutoDetectedLMStudio {
}

/** Connect to LM Studio's documented default local endpoint when it validates. */
export async function autoDetectLMStudio(): Promise<AutoDetectedLMStudio | undefined> {
export interface AutoDetectOptions {
readonly onError?: (error: unknown) => void
}

export async function autoDetectLMStudio(
options: AutoDetectOptions = {},
): Promise<AutoDetectedLMStudio | undefined> {
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.
}
}
Expand Down
Loading