feat: add Ollama discovery and unified local models wizard
- Add `clawdbot models ollama setup` and `clawdbot models ollama discover` - Add `clawdbot models local` unified wizard that auto-detects both LM Studio and Ollama - Server URL prompt in setup commands allows configuring remote servers - Update docs with new CLI commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
899f0fa907
commit
4ce940e590
@ -15,22 +15,27 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
|
||||
### Quick setup (auto-discovery)
|
||||
|
||||
The fastest way to configure LM Studio:
|
||||
The fastest way to configure local models:
|
||||
|
||||
```bash
|
||||
# Discover and configure models from default URL (127.0.0.1:1234)
|
||||
# Auto-detect LM Studio and Ollama, configure what's available
|
||||
clawdbot models local --set-default
|
||||
|
||||
# LM Studio specific
|
||||
clawdbot models lmstudio setup --set-default
|
||||
clawdbot models lmstudio setup --url http://your-server:1234/v1 --set-default
|
||||
clawdbot models lmstudio discover --json
|
||||
|
||||
# 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
|
||||
# Ollama specific
|
||||
clawdbot models ollama setup --set-default
|
||||
clawdbot models ollama setup --url http://your-server:11434/v1 --set-default
|
||||
clawdbot models ollama discover --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.
|
||||
|
||||
The setup wizard prompts for the server URL - enter a custom URL for remote servers or accept the default (127.0.0.1) for local.
|
||||
|
||||
### Manual configuration
|
||||
|
||||
```json5
|
||||
|
||||
@ -23,6 +23,9 @@ import {
|
||||
modelsListCommand,
|
||||
modelsLMStudioDiscoverCommand,
|
||||
modelsLMStudioSetupCommand,
|
||||
modelsLocalSetupCommand,
|
||||
modelsOllamaDiscoverCommand,
|
||||
modelsOllamaSetupCommand,
|
||||
modelsScanCommand,
|
||||
modelsSetCommand,
|
||||
modelsSetImageCommand,
|
||||
@ -307,6 +310,65 @@ export function registerModelsCli(program: Command) {
|
||||
});
|
||||
});
|
||||
|
||||
// Ollama commands
|
||||
const ollama = models
|
||||
.command("ollama")
|
||||
.description("Ollama local model setup and discovery");
|
||||
|
||||
ollama
|
||||
.command("setup")
|
||||
.description("Discover and configure Ollama models")
|
||||
.option("--url <url>", "Ollama server URL (default: http://127.0.0.1:11434/v1)")
|
||||
.option("--set-default", "Set discovered model as default", false)
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.action(async (opts) => {
|
||||
await runModelsCommand(async () => {
|
||||
await modelsOllamaSetupCommand(
|
||||
{
|
||||
url: opts.url as string | undefined,
|
||||
setDefault: Boolean(opts.setDefault),
|
||||
yes: Boolean(opts.yes),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ollama
|
||||
.command("discover")
|
||||
.description("List models available on Ollama server")
|
||||
.option("--url <url>", "Ollama server URL (default: http://127.0.0.1:11434/v1)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runModelsCommand(async () => {
|
||||
await modelsOllamaDiscoverCommand(
|
||||
{
|
||||
url: opts.url as string | undefined,
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Unified local models command
|
||||
models
|
||||
.command("local")
|
||||
.description("Auto-detect and configure local model servers (LM Studio, Ollama)")
|
||||
.option("--set-default", "Set discovered model as default", false)
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.action(async (opts) => {
|
||||
await runModelsCommand(async () => {
|
||||
await modelsLocalSetupCommand(
|
||||
{
|
||||
setDefault: Boolean(opts.setDefault),
|
||||
yes: Boolean(opts.yes),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
models.action(async (opts) => {
|
||||
await runModelsCommand(async () => {
|
||||
await modelsStatusCommand(
|
||||
|
||||
@ -31,4 +31,10 @@ 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";
|
||||
export {
|
||||
modelsLMStudioSetupCommand,
|
||||
modelsLMStudioDiscoverCommand,
|
||||
modelsOllamaSetupCommand,
|
||||
modelsOllamaDiscoverCommand,
|
||||
modelsLocalSetupCommand,
|
||||
} from "./models/lmstudio.js";
|
||||
|
||||
@ -7,16 +7,82 @@ import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { updateConfig } from "./shared.js";
|
||||
|
||||
const DEFAULT_LMSTUDIO_URL = "http://127.0.0.1:1234/v1";
|
||||
export const DEFAULT_LMSTUDIO_URL = "http://127.0.0.1:1234/v1";
|
||||
export const DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434/v1";
|
||||
const OLLAMA_API_URL = "http://127.0.0.1:11434";
|
||||
|
||||
export interface LMStudioSetupOptions {
|
||||
export interface LocalModelSetupOptions {
|
||||
url?: string;
|
||||
setDefault?: boolean;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
modified_at: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OllamaTagsResponse {
|
||||
models: OllamaModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from Ollama via /api/tags endpoint.
|
||||
*/
|
||||
export async function discoverOllamaModelsFromUrl(
|
||||
baseUrl?: string,
|
||||
): Promise<ModelDefinitionConfig[]> {
|
||||
const apiUrl = baseUrl?.replace(/\/v1\/?$/, "") ?? OLLAMA_API_URL;
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/tags`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const data = (await response.json()) as OllamaTagsResponse;
|
||||
if (!data.models || data.models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return data.models.map((model) => {
|
||||
const modelId = model.name;
|
||||
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-") ||
|
||||
idLower.includes("llava");
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning: isReasoning,
|
||||
input: isVision ? (["text", "image"] as const) : (["text"] as const),
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LM Studio Commands
|
||||
// ============================================================================
|
||||
|
||||
export async function modelsLMStudioSetupCommand(
|
||||
opts: LMStudioSetupOptions,
|
||||
opts: LocalModelSetupOptions,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
let baseUrl = opts.url ?? process.env.LMSTUDIO_BASE_URL ?? DEFAULT_LMSTUDIO_URL;
|
||||
@ -102,7 +168,7 @@ export async function modelsLMStudioSetupCommand(
|
||||
};
|
||||
|
||||
// Update config
|
||||
const updated = await updateConfig((cfg) => {
|
||||
await updateConfig((cfg) => {
|
||||
const nextConfig = {
|
||||
...cfg,
|
||||
models: {
|
||||
@ -147,10 +213,7 @@ export async function modelsLMStudioSetupCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Show discovered models summary
|
||||
runtime.log(
|
||||
theme.muted(`\nConfigured ${models.length} model(s) from ${baseUrl}`),
|
||||
);
|
||||
runtime.log(theme.muted(`\nConfigured ${models.length} model(s) from ${baseUrl}`));
|
||||
}
|
||||
|
||||
export async function modelsLMStudioDiscoverCommand(
|
||||
@ -180,3 +243,248 @@ export async function modelsLMStudioDiscoverCommand(
|
||||
runtime.log(` ${model.id}${tagStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ollama Commands
|
||||
// ============================================================================
|
||||
|
||||
export async function modelsOllamaSetupCommand(
|
||||
opts: LocalModelSetupOptions,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
let baseUrl = opts.url ?? process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL;
|
||||
|
||||
// If no URL provided and not --yes, prompt for it
|
||||
if (!opts.url && !opts.yes) {
|
||||
const urlInput = await clack.text({
|
||||
message: "Ollama server URL",
|
||||
placeholder: DEFAULT_OLLAMA_URL,
|
||||
defaultValue: DEFAULT_OLLAMA_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_OLLAMA_URL;
|
||||
}
|
||||
|
||||
// Discover models
|
||||
const spinner = clack.spinner();
|
||||
spinner.start(`Discovering models at ${baseUrl}...`);
|
||||
|
||||
const models = await discoverOllamaModelsFromUrl(baseUrl);
|
||||
|
||||
if (models.length === 0) {
|
||||
spinner.stop(`${theme.error("No models found")} at ${baseUrl}`);
|
||||
runtime.log(theme.muted("\nMake sure Ollama is running. Start with: ollama serve"));
|
||||
runtime.log(theme.muted(`Test with: curl ${baseUrl.replace(/\/v1\/?$/, "")}/api/tags`));
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.stop(`Found ${models.length} model(s)`);
|
||||
|
||||
// Display discovered models
|
||||
runtime.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(", ")})`)}` : "";
|
||||
runtime.log(` ${theme.success("+")} ${model.id}${tagStr}`);
|
||||
}
|
||||
runtime.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 - ensure baseUrl ends with /v1
|
||||
const normalizedUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl.replace(/\/$/, "")}/v1`;
|
||||
const providerConfig = {
|
||||
baseUrl: normalizedUrl,
|
||||
apiKey: "ollama",
|
||||
api: "openai-completions" as const,
|
||||
models,
|
||||
};
|
||||
|
||||
// Update config
|
||||
await updateConfig((cfg) => {
|
||||
const nextConfig = {
|
||||
...cfg,
|
||||
models: {
|
||||
...cfg.models,
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
ollama: providerConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Set as default if requested
|
||||
if (opts.setDefault && selectedModel) {
|
||||
const modelId = `ollama/${selectedModel.id}`;
|
||||
const existingModel = cfg.agents?.defaults?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
nextConfig.agents = {
|
||||
...nextConfig.agents,
|
||||
defaults: {
|
||||
...nextConfig.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel?.fallbacks ? { fallbacks: existingModel.fallbacks } : undefined),
|
||||
primary: modelId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
});
|
||||
|
||||
logConfigUpdated(runtime);
|
||||
|
||||
if (selectedModel) {
|
||||
const modelId = `ollama/${selectedModel.id}`;
|
||||
if (opts.setDefault) {
|
||||
runtime.log(`Default model: ${theme.accent(modelId)}`);
|
||||
} else {
|
||||
runtime.log(theme.muted(`\nTo set as default: clawdbot models set ${modelId}`));
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log(theme.muted(`\nConfigured ${models.length} model(s) from ${baseUrl}`));
|
||||
}
|
||||
|
||||
export async function modelsOllamaDiscoverCommand(
|
||||
opts: { url?: string; json?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
const baseUrl = opts.url ?? process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL;
|
||||
const models = await discoverOllamaModelsFromUrl(baseUrl);
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ baseUrl, models }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
runtime.log(theme.error(`No models found at ${baseUrl}`));
|
||||
runtime.log(theme.muted(`\nMake sure Ollama is running. Start with: ollama serve`));
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(`Models at ${theme.accent(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(", ")})`)}` : "";
|
||||
runtime.log(` ${model.id}${tagStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unified Local Models Wizard
|
||||
// ============================================================================
|
||||
|
||||
export async function modelsLocalSetupCommand(
|
||||
opts: LocalModelSetupOptions,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
// Auto-detect available servers
|
||||
const spinner = clack.spinner();
|
||||
spinner.start("Detecting local model servers...");
|
||||
|
||||
const [lmstudioModels, ollamaModels] = await Promise.all([
|
||||
discoverLMStudioModels(opts.url ?? DEFAULT_LMSTUDIO_URL),
|
||||
discoverOllamaModelsFromUrl(opts.url ?? DEFAULT_OLLAMA_URL),
|
||||
]);
|
||||
|
||||
const hasLmstudio = lmstudioModels.length > 0;
|
||||
const hasOllama = ollamaModels.length > 0;
|
||||
|
||||
if (!hasLmstudio && !hasOllama) {
|
||||
spinner.stop(theme.error("No local model servers detected"));
|
||||
runtime.log("");
|
||||
runtime.log(theme.muted("Start one of the following:"));
|
||||
runtime.log(theme.muted(" - LM Studio: Download from https://lmstudio.ai, load a model, start server"));
|
||||
runtime.log(theme.muted(" - Ollama: Install from https://ollama.ai, run: ollama serve"));
|
||||
runtime.log("");
|
||||
runtime.log(theme.muted("Or specify a custom URL:"));
|
||||
runtime.log(theme.muted(" clawdbot models lmstudio setup --url http://your-server:1234/v1"));
|
||||
runtime.log(theme.muted(" clawdbot models ollama setup --url http://your-server:11434/v1"));
|
||||
return;
|
||||
}
|
||||
|
||||
const detected: string[] = [];
|
||||
if (hasLmstudio) detected.push(`LM Studio (${lmstudioModels.length} models)`);
|
||||
if (hasOllama) detected.push(`Ollama (${ollamaModels.length} models)`);
|
||||
spinner.stop(`Detected: ${detected.join(", ")}`);
|
||||
|
||||
// If only one server available, use it directly
|
||||
if (hasLmstudio && !hasOllama) {
|
||||
await modelsLMStudioSetupCommand({ ...opts, url: opts.url ?? DEFAULT_LMSTUDIO_URL }, runtime);
|
||||
return;
|
||||
}
|
||||
if (hasOllama && !hasLmstudio) {
|
||||
await modelsOllamaSetupCommand({ ...opts, url: opts.url ?? DEFAULT_OLLAMA_URL }, runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both available - let user choose
|
||||
if (!opts.yes) {
|
||||
const choice = await clack.select({
|
||||
message: "Which local server do you want to configure?",
|
||||
options: [
|
||||
{ value: "lmstudio", label: "LM Studio", hint: `${lmstudioModels.length} models` },
|
||||
{ value: "ollama", label: "Ollama", hint: `${ollamaModels.length} models` },
|
||||
{ value: "both", label: "Both", hint: "Configure both servers" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice)) {
|
||||
clack.cancel("Setup cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === "lmstudio" || choice === "both") {
|
||||
await modelsLMStudioSetupCommand({ ...opts, url: opts.url ?? DEFAULT_LMSTUDIO_URL }, runtime);
|
||||
}
|
||||
if (choice === "ollama" || choice === "both") {
|
||||
await modelsOllamaSetupCommand({ ...opts, url: opts.url ?? DEFAULT_OLLAMA_URL }, runtime);
|
||||
}
|
||||
} else {
|
||||
// --yes mode: configure both
|
||||
await modelsLMStudioSetupCommand({ ...opts, url: opts.url ?? DEFAULT_LMSTUDIO_URL }, runtime);
|
||||
await modelsOllamaSetupCommand({ ...opts, url: opts.url ?? DEFAULT_OLLAMA_URL }, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user