From 861af7bd529d3e0de486b3d62c380c23aca5e721 Mon Sep 17 00:00:00 2001 From: Eugene Kniazev Date: Tue, 27 Jan 2026 11:32:55 +0000 Subject: [PATCH] feat: add LM Studio auto-discovery and CLI setup command - Add discoverLMStudioModels() for auto-discovering models via /v1/models endpoint - Add implicit LM Studio provider support (LMSTUDIO_BASE_URL or LMSTUDIO_API_KEY) - Add `clawdbot models lmstudio setup` command for interactive configuration - Add `clawdbot models lmstudio discover` command to list available models - Update local-models.md docs with quick setup instructions This brings LM Studio on par with Ollama for ease of setup - no more manual model definitions required. Co-Authored-By: Claude Opus 4.5 --- docs/gateway/local-models.md | 20 +++ src/agents/models-config.providers.ts | 97 ++++++++++++++ src/cli/models-cli.ts | 42 +++++++ src/commands/models.ts | 1 + src/commands/models/lmstudio.ts | 175 ++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 src/commands/models/lmstudio.ts diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 0c85423c6..b45c7d078 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -13,6 +13,26 @@ Local is doable, but Clawdbot expects large context + strong defenses against pr Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text. +### Quick setup (auto-discovery) + +The fastest way to configure LM Studio: + +```bash +# Discover and configure models from default URL (127.0.0.1:1234) +clawdbot models lmstudio setup --set-default + +# Or specify a custom URL (e.g., remote server) +clawdbot models lmstudio setup --url http://brian:1234/v1 --set-default + +# Just list available models without configuring +clawdbot models lmstudio discover +clawdbot models lmstudio discover --url http://brian:1234/v1 --json +``` + +This auto-discovers all loaded models and adds them to your config. Use `--set-default` to also set the first model as your primary. + +### Manual configuration + ```json5 { agents: { diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 996f09dd0..b8fab2dec 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -75,6 +75,16 @@ const OLLAMA_DEFAULT_COST = { cacheWrite: 0, }; +const LMSTUDIO_DEFAULT_BASE_URL = "http://127.0.0.1:1234/v1"; +const LMSTUDIO_DEFAULT_CONTEXT_WINDOW = 128000; +const LMSTUDIO_DEFAULT_MAX_TOKENS = 8192; +const LMSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + interface OllamaModel { name: string; modified_at: string; @@ -90,6 +100,17 @@ interface OllamaTagsResponse { models: OllamaModel[]; } +interface LMStudioModel { + id: string; + object: string; + owned_by: string; +} + +interface LMStudioModelsResponse { + data: LMStudioModel[]; + object: string; +} + async function discoverOllamaModels(): Promise { // Skip Ollama discovery in test environments if (process.env.VITEST || process.env.NODE_ENV === "test") { @@ -128,6 +149,70 @@ async function discoverOllamaModels(): Promise { } } +/** + * Discover models from an LM Studio instance via OpenAI-compatible /v1/models endpoint. + * Filters out embedding models and identifies reasoning models by name patterns. + */ +export async function discoverLMStudioModels(baseUrl?: string): Promise { + // Skip discovery in test environments + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return []; + } + const url = baseUrl ?? LMSTUDIO_DEFAULT_BASE_URL; + try { + const response = await fetch(`${url}/models`, { + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + return []; + } + const data = (await response.json()) as LMStudioModelsResponse; + if (!data.data || data.data.length === 0) { + return []; + } + return data.data + .filter((model) => { + // Filter out embedding models + const id = model.id.toLowerCase(); + return !id.includes("embedding") && !id.includes("embed-"); + }) + .map((model) => { + const modelId = model.id; + const idLower = modelId.toLowerCase(); + const isReasoning = + idLower.includes("r1") || + idLower.includes("reasoning") || + idLower.includes("think"); + const isVision = + idLower.includes("vision") || + idLower.includes("-vl") || + idLower.includes("vl-"); + return { + id: modelId, + name: modelId, + reasoning: isReasoning, + input: isVision ? (["text", "image"] as const) : (["text"] as const), + cost: LMSTUDIO_DEFAULT_COST, + contextWindow: LMSTUDIO_DEFAULT_CONTEXT_WINDOW, + maxTokens: LMSTUDIO_DEFAULT_MAX_TOKENS, + }; + }); + } catch { + return []; + } +} + +async function buildLMStudioProvider(baseUrl?: string): Promise { + const url = baseUrl ?? LMSTUDIO_DEFAULT_BASE_URL; + const models = await discoverLMStudioModels(url); + return { + baseUrl: url, + apiKey: "lmstudio", + api: "openai-completions", + models, + }; +} + function normalizeApiKeyConfig(value: string): string { const trimmed = value.trim(); const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed); @@ -418,6 +503,18 @@ export async function resolveImplicitProviders(params: { providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; } + // LM Studio provider - add if LMSTUDIO_API_KEY or LMSTUDIO_BASE_URL is set, or auth profile exists + const lmstudioKey = + resolveEnvApiKeyVarName("lmstudio") ?? + resolveApiKeyFromProfiles({ provider: "lmstudio", store: authStore }); + const lmstudioBaseUrl = process.env.LMSTUDIO_BASE_URL; + if (lmstudioKey || lmstudioBaseUrl) { + const provider = await buildLMStudioProvider(lmstudioBaseUrl); + if (provider.models.length > 0) { + providers.lmstudio = { ...provider, apiKey: lmstudioKey ?? "lmstudio" }; + } + } + return providers; } diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index d914629e7..461175bed 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -21,6 +21,8 @@ import { modelsImageFallbacksListCommand, modelsImageFallbacksRemoveCommand, modelsListCommand, + modelsLMStudioDiscoverCommand, + modelsLMStudioSetupCommand, modelsScanCommand, modelsSetCommand, modelsSetImageCommand, @@ -265,6 +267,46 @@ export function registerModelsCli(program: Command) { }); }); + const lmstudio = models + .command("lmstudio") + .description("LM Studio local model setup and discovery"); + + lmstudio + .command("setup") + .description("Discover and configure LM Studio models") + .option("--url ", "LM Studio server URL (default: http://127.0.0.1:1234/v1)") + .option("--set-default", "Set discovered model as default", false) + .option("--yes", "Accept defaults without prompting", false) + .action(async (opts) => { + await runModelsCommand(async () => { + await modelsLMStudioSetupCommand( + { + url: opts.url as string | undefined, + setDefault: Boolean(opts.setDefault), + yes: Boolean(opts.yes), + }, + defaultRuntime, + ); + }); + }); + + lmstudio + .command("discover") + .description("List models available on LM Studio server") + .option("--url ", "LM Studio server URL (default: http://127.0.0.1:1234/v1)") + .option("--json", "Output JSON", false) + .action(async (opts) => { + await runModelsCommand(async () => { + await modelsLMStudioDiscoverCommand( + { + url: opts.url as string | undefined, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + models.action(async (opts) => { await runModelsCommand(async () => { await modelsStatusCommand( diff --git a/src/commands/models.ts b/src/commands/models.ts index 5a1c103c8..75a477b2b 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -31,3 +31,4 @@ export { modelsListCommand, modelsStatusCommand } from "./models/list.js"; export { modelsScanCommand } from "./models/scan.js"; export { modelsSetCommand } from "./models/set.js"; export { modelsSetImageCommand } from "./models/set-image.js"; +export { modelsLMStudioSetupCommand, modelsLMStudioDiscoverCommand } from "./models/lmstudio.js"; diff --git a/src/commands/models/lmstudio.ts b/src/commands/models/lmstudio.ts new file mode 100644 index 000000000..357823108 --- /dev/null +++ b/src/commands/models/lmstudio.ts @@ -0,0 +1,175 @@ +import * as clack from "@clack/prompts"; + +import { discoverLMStudioModels } from "../../agents/models-config.providers.js"; +import { readConfig, writeConfig } from "../../config/config.js"; +import type { ModelDefinitionConfig } from "../../config/types.models.js"; +import type { Runtime } from "../../runtime.js"; +import { theme } from "../../terminal/theme.js"; + +const DEFAULT_LMSTUDIO_URL = "http://127.0.0.1:1234/v1"; + +export interface LMStudioSetupOptions { + url?: string; + setDefault?: boolean; + yes?: boolean; +} + +export async function modelsLMStudioSetupCommand( + opts: LMStudioSetupOptions, + runtime: Runtime, +): Promise { + const config = readConfig(runtime.configPath); + + let baseUrl = opts.url ?? process.env.LMSTUDIO_BASE_URL ?? DEFAULT_LMSTUDIO_URL; + + // If no URL provided and not --yes, prompt for it + if (!opts.url && !opts.yes) { + const urlInput = await clack.text({ + message: "LM Studio server URL", + placeholder: DEFAULT_LMSTUDIO_URL, + defaultValue: DEFAULT_LMSTUDIO_URL, + validate: (value) => { + try { + new URL(value); + return undefined; + } catch { + return "Invalid URL"; + } + }, + }); + if (clack.isCancel(urlInput)) { + clack.cancel("Setup cancelled"); + return; + } + baseUrl = urlInput || DEFAULT_LMSTUDIO_URL; + } + + // Discover models + const spinner = clack.spinner(); + spinner.start(`Discovering models at ${baseUrl}...`); + + const models = await discoverLMStudioModels(baseUrl); + + if (models.length === 0) { + spinner.stop(`${theme.error("No models found")} at ${baseUrl}`); + console.log(theme.muted("\nMake sure LM Studio is running and has a model loaded.")); + console.log(theme.muted(`Test with: curl ${baseUrl}/models`)); + return; + } + + spinner.stop(`Found ${models.length} model(s)`); + + // Display discovered models + console.log(); + for (const model of models) { + const tags: string[] = []; + if (model.reasoning) tags.push("reasoning"); + if (model.input?.includes("image")) tags.push("vision"); + const tagStr = tags.length > 0 ? ` ${theme.muted(`(${tags.join(", ")})`)}` : ""; + console.log(` ${theme.success("+")} ${model.id}${tagStr}`); + } + console.log(); + + // Select default model + let selectedModel: ModelDefinitionConfig | undefined; + if (!opts.yes && models.length > 1) { + const modelOptions = models.map((m) => ({ + value: m.id, + label: m.id, + hint: m.reasoning ? "reasoning" : undefined, + })); + + const selected = await clack.select({ + message: "Select default model", + options: modelOptions, + }); + + if (clack.isCancel(selected)) { + clack.cancel("Setup cancelled"); + return; + } + + selectedModel = models.find((m) => m.id === selected); + } else { + selectedModel = models[0]; + } + + // Build provider config + const providerConfig = { + baseUrl, + apiKey: "lmstudio", + api: "openai-completions" as const, + models, + }; + + // Update config + const nextConfig = { + ...config, + models: { + ...config.models, + mode: config.models?.mode ?? "merge", + providers: { + ...config.models?.providers, + lmstudio: providerConfig, + }, + }, + }; + + // Set as default if requested + if (opts.setDefault && selectedModel) { + const modelId = `lmstudio/${selectedModel.id}`; + nextConfig.agents = { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...nextConfig.agents?.defaults?.model, + primary: modelId, + }, + }, + }; + } + + writeConfig(runtime.configPath, nextConfig); + + console.log(theme.success(`Updated ${runtime.configPath}`)); + + if (selectedModel) { + const modelId = `lmstudio/${selectedModel.id}`; + if (opts.setDefault) { + console.log(`Default model: ${theme.highlight(modelId)}`); + } else { + console.log( + theme.muted(`\nTo set as default: clawdbot models set ${modelId}`), + ); + } + } +} + +export async function modelsLMStudioDiscoverCommand( + opts: { url?: string; json?: boolean }, + _runtime: Runtime, +): Promise { + const baseUrl = opts.url ?? process.env.LMSTUDIO_BASE_URL ?? DEFAULT_LMSTUDIO_URL; + const models = await discoverLMStudioModels(baseUrl); + + if (opts.json) { + console.log(JSON.stringify({ baseUrl, models }, null, 2)); + return; + } + + if (models.length === 0) { + console.log(theme.error(`No models found at ${baseUrl}`)); + console.log(theme.muted(`\nMake sure LM Studio is running and has a model loaded.`)); + return; + } + + console.log(`Models at ${theme.highlight(baseUrl)}:\n`); + for (const model of models) { + const tags: string[] = []; + if (model.reasoning) tags.push("reasoning"); + if (model.input?.includes("image")) tags.push("vision"); + const tagStr = tags.length > 0 ? ` ${theme.muted(`(${tags.join(", ")})`)}` : ""; + console.log(` ${model.id}${tagStr}`); + } +}