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 <noreply@anthropic.com>
This commit is contained in:
parent
f4004054ab
commit
861af7bd52
@ -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: {
|
||||
|
||||
@ -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<ModelDefinitionConfig[]> {
|
||||
// Skip Ollama discovery in test environments
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
@ -128,6 +149,70 @@ async function discoverOllamaModels(): Promise<ModelDefinitionConfig[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ModelDefinitionConfig[]> {
|
||||
// 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<ProviderConfig> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 <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 <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(
|
||||
|
||||
@ -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";
|
||||
|
||||
175
src/commands/models/lmstudio.ts
Normal file
175
src/commands/models/lmstudio.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user