feat(model): add /model picker

This commit is contained in:
Peter Steinberger
2026-01-12 06:02:39 +00:00
parent 121c9bd6f3
commit 2da2057a37
10 changed files with 434 additions and 136 deletions

View File

@@ -11,6 +11,7 @@
### Changes
- CLI: simplify configure section selection (single-select with optional add-more).
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.

View File

@@ -151,8 +151,8 @@ Use the interactive config wizard to set MiniMax without editing JSON:
## Configuration options
- `models.providers.minimax.baseUrl`: `https://api.minimax.io/v1` or `https://api.minimax.io/anthropic`.
- `models.providers.minimax.api`: `openai-completions` (cloud) or `anthropic-messages` (API).
- `models.providers.minimax.baseUrl`: prefer `https://api.minimax.io/anthropic` (Anthropic-compatible); `https://api.minimax.io/v1` is optional for OpenAI-compatible payloads.
- `models.providers.minimax.api`: prefer `anthropic-messages`; `openai-completions` is optional for OpenAI-compatible payloads.
- `models.providers.minimax.apiKey`: MiniMax API key (`MINIMAX_API_KEY`).
- `models.providers.minimax.models`: define `id`, `name`, `reasoning`, `contextWindow`, `maxTokens`, `cost`.
- `agents.defaults.models`: alias models you want in the allowlist.

View File

@@ -496,6 +496,12 @@ Use the `/model` command as a standalone message:
You can list available models with `/model`, `/model list`, or `/model status`.
`/model` (and `/model list`) shows a compact, numbered picker. Select by number:
```
/model 3
```
You can also force a specific auth profile for the provider (per session):
```
@@ -504,6 +510,7 @@ You can also force a specific auth profile for the provider (per session):
```
Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next.
It also shows the configured provider endpoint (`baseUrl`) and API mode (`api`) when available.
### Why do I see “Model … is not allowed” and then no reply?

View File

@@ -611,12 +611,12 @@ describe("runEmbeddedPiAgent", () => {
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/v1",
api: "openai-completions",
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "sk-minimax-test",
models: [
{
id: "minimax-m2.1",
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],

View File

@@ -1449,9 +1449,9 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).not.toContain("claude-sonnet-4-1");
expect(text).toContain("auth:");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -1512,9 +1512,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("auth:");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -1544,9 +1544,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model catalog unavailable");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -1574,7 +1574,6 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("auth:");
expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -27,6 +27,30 @@ const usageMocks = vi.hoisted(() => ({
vi.mock("../infra/provider-usage.js", () => usageMocks);
const modelCatalogMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-5",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
@@ -264,6 +288,102 @@ describe("trigger handling", () => {
});
});
it("shows a quick /model picker grouped by model with providers", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain(
"Pick: /model <#> or /model <provider/model>",
);
expect(normalized).toContain(
"1) claude-opus-4-5 — anthropic, openrouter",
);
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
});
});
it("selects a model by index via /model <#>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const res = await getReplyFromConfig(
{
Body: "/model 3",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openai/gpt-5.2",
);
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openai");
expect(store[sessionKey]?.modelOverride).toBe("gpt-5.2");
});
});
it("includes endpoint details in /model status when configured", async () => {
await withTempHome(async (home) => {
const cfg = {
...makeCfg(home),
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
},
},
},
};
const res = await getReplyFromConfig(
{
Body: "/model status",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain(
"[minimax] endpoint: https://api.minimax.io/anthropic api: anthropic-messages auth:",
);
});
});
it("rejects /restart by default", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(

View File

@@ -316,6 +316,121 @@ const resolveProfileOverride = (params: {
return { profileId: raw };
};
type ModelPickerCatalogEntry = {
provider: string;
id: string;
name?: string;
};
type ModelPickerItem = {
model: string;
providers: string[];
providerModels: Record<string, string>;
};
const MODEL_PICK_PROVIDER_PREFERENCE = [
"anthropic",
"openai",
"openai-codex",
"minimax",
"google",
"zai",
"openrouter",
"opencode",
"github-copilot",
"groq",
"cerebras",
"mistral",
"xai",
"lmstudio",
] as const;
function normalizeModelFamilyId(id: string): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const parts = trimmed.split("/").filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed;
}
function sortProvidersForPicker(providers: string[]): string[] {
const pref = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
return providers.sort((a, b) => {
const pa = pref.get(a);
const pb = pref.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
});
}
function buildModelPickerItems(
catalog: ModelPickerCatalogEntry[],
): ModelPickerItem[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>();
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
const model = normalizeModelFamilyId(entry.id);
if (!provider || !model) continue;
const existing = byModel.get(model);
if (existing) {
existing.providerModels[provider] = entry.id;
continue;
}
byModel.set(model, { providerModels: { [provider]: entry.id } });
}
const out: ModelPickerItem[] = [];
for (const [model, data] of byModel.entries()) {
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
out.push({ model, providers, providerModels: data.providerModels });
}
out.sort((a, b) =>
a.model.toLowerCase().localeCompare(b.model.toLowerCase()),
);
return out;
}
function pickProviderForModel(params: {
item: ModelPickerItem;
preferredProvider?: string;
}): { provider: string; model: string } | null {
const preferred = params.preferredProvider
? normalizeProviderId(params.preferredProvider)
: undefined;
if (preferred && params.item.providerModels[preferred]) {
return {
provider: preferred,
model: params.item.providerModels[preferred],
};
}
const first = params.item.providers[0];
if (!first) return null;
return {
provider: first,
model: params.item.providerModels[first] ?? params.item.model,
};
}
function resolveProviderEndpointLabel(
provider: string,
cfg: ClawdbotConfig,
): { endpoint?: string; api?: string } {
const normalized = normalizeProviderId(provider);
const providers = (cfg.models?.providers ?? {}) as Record<
string,
{ baseUrl?: string; api?: string } | undefined
>;
const entry = providers[normalized];
const endpoint = entry?.baseUrl?.trim();
const api = entry?.api?.trim();
return {
endpoint: endpoint || undefined,
api: api || undefined,
};
}
export type InlineDirectives = {
cleaned: string;
hasThinkDirective: boolean;
@@ -527,111 +642,90 @@ export async function handleDirectiveOnly(params: {
directives.hasElevatedDirective && !runtimeIsSandboxed;
if (directives.hasModelDirective) {
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
const isModelListAlias =
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
const rawDirective = directives.rawModelDirective?.trim();
const directive = rawDirective?.toLowerCase();
const wantsStatus = directive === "status";
const wantsList = !rawDirective || directive === "list";
if ((wantsList || wantsStatus) && directives.rawModelProfile) {
return { text: "Auth profile override requires a model selection." };
}
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
if (allowedModelCatalog.length > 0) return allowedModelCatalog;
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (keys.has(key)) continue;
keys.add(key);
out.push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (out.length === 0 && resolvedDefault.model) {
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
keys.add(key);
out.push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
if (wantsList) {
const items = buildModelPickerItems(pickerCatalog);
if (items.length === 0) return { text: "No models available." };
const current = `${params.provider}/${params.model}`;
const lines: string[] = [
`Current: ${current}`,
"Pick: /model <#> or /model <provider/model>",
];
for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.model}${item.providers.join(", ")}`);
}
lines.push("", "More: /model status");
return { text: lines.join("\n") };
}
if (wantsStatus) {
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authMode: ModelAuthDetailMode =
modelDirective === "status" ? "verbose" : "compact";
if (allowedModelCatalog.length === 0) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const fallbackKeys = new Set<string>();
const fallbackCatalog: Array<{
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (fallbackKeys.has(key)) continue;
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolved.ref.provider,
id: resolved.ref.model,
});
}
if (fallbackCatalog.length === 0 && resolvedDefault.model) {
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
fallbackKeys.add(key);
fallbackCatalog.push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
});
}
if (fallbackCatalog.length === 0) {
return { text: "No models available." };
}
const authByProvider = new Map<string, string>();
for (const entry of fallbackCatalog) {
if (authByProvider.has(entry.provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Agent: ${activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
`⚠️ Model catalog unavailable; showing configured models only.`,
];
const byProvider = new Map<string, typeof fallbackCatalog>();
for (const entry of fallbackCatalog) {
const models = byProvider.get(entry.provider);
if (models) {
models.push(entry);
continue;
}
byProvider.set(entry.provider, [entry]);
}
for (const provider of byProvider.keys()) {
const models = byProvider.get(provider);
if (!models) continue;
const authLabel = authByProvider.get(provider) ?? "missing";
lines.push("");
lines.push(`[${provider}] auth: ${authLabel}`);
for (const entry of models) {
const label = `${entry.provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
lines.push(`${label}${aliasSuffix}`);
}
}
return { text: lines.join("\n") };
}
const authMode: ModelAuthDetailMode = "verbose";
const catalog = pickerCatalog;
if (catalog.length === 0) return { text: "No models available." };
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue;
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
if (authByProvider.has(provider)) continue;
const auth = await resolveAuthLabel(
entry.provider,
provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
authByProvider.set(provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const lines = [
@@ -644,26 +738,32 @@ export async function handleDirectiveOnly(params: {
lines.push(`(previous selection reset to default)`);
}
// Group models by provider
const byProvider = new Map<string, typeof allowedModelCatalog>();
for (const entry of allowedModelCatalog) {
const models = byProvider.get(entry.provider);
const byProvider = new Map<string, ModelPickerCatalogEntry[]>();
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
const models = byProvider.get(provider);
if (models) {
models.push(entry);
continue;
}
byProvider.set(entry.provider, [entry]);
byProvider.set(provider, [entry]);
}
// Iterate over provider groups
for (const provider of byProvider.keys()) {
const models = byProvider.get(provider);
if (!models) continue;
const authLabel = authByProvider.get(provider) ?? "missing";
const endpoint = resolveProviderEndpointLabel(provider, params.cfg);
const endpointSuffix = endpoint.endpoint
? ` endpoint: ${endpoint.endpoint}`
: " endpoint: default";
const apiSuffix = endpoint.api ? ` api: ${endpoint.api}` : "";
lines.push("");
lines.push(`[${provider}] auth: ${authLabel}`);
lines.push(
`[${provider}]${endpointSuffix}${apiSuffix} auth: ${authLabel}`,
);
for (const entry of models) {
const label = `${entry.provider}/${entry.id}`;
const label = `${provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
@@ -672,9 +772,6 @@ export async function handleDirectiveOnly(params: {
}
return { text: lines.join("\n") };
}
if (directives.rawModelProfile && !modelDirective) {
throw new Error("Auth profile override requires a model selection.");
}
}
if (directives.hasThinkDirective && !directives.thinkLevel) {
@@ -835,17 +932,87 @@ export async function handleDirectiveOnly(params: {
let modelSelection: ModelDirectiveSelection | undefined;
let profileOverride: string | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const resolved = resolveModelDirectiveSelection({
raw: directives.rawModelDirective,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
const raw = directives.rawModelDirective.trim();
if (/^[0-9]+$/.test(raw)) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider,
defaultModel,
});
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
if (allowedModelCatalog.length > 0) return allowedModelCatalog;
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
for (const rawKey of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(rawKey),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (keys.has(key)) continue;
keys.add(key);
out.push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
if (out.length === 0 && resolvedDefault.model) {
const key = modelKey(resolvedDefault.provider, resolvedDefault.model);
keys.add(key);
out.push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
})();
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
text: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const aliases = aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault:
picked.provider === defaultProvider && picked.model === defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
}
modelSelection = resolved.selection;
if (modelSelection) {
if (directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({

View File

@@ -48,10 +48,7 @@ export async function promptAuthChoiceGrouped(params: {
const methodSelection = (await params.prompter.select({
message: `${group.label} auth method`,
options: [
...group.options,
{ value: BACK_VALUE, label: "Back" },
],
options: [...group.options, { value: BACK_VALUE, label: "Back" }],
})) as string;
if (methodSelection === BACK_VALUE) {

View File

@@ -85,6 +85,13 @@ describe("applyAuthChoice", () => {
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: "Enter MiniMax API key" }),
);
expect(result.config.models?.providers?.minimax).toMatchObject({
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
});
expect(result.config.agents?.defaults?.model).toMatchObject({
primary: "minimax/MiniMax-M2.1",
});
expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({
provider: "minimax",
mode: "api_key",

View File

@@ -31,12 +31,12 @@ import type {
ResetScope,
} from "./onboard-types.js";
export function guardCancel<T>(value: T, runtime: RuntimeEnv): T {
export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
if (isCancel(value)) {
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
runtime.exit(0);
}
return value;
return value as T;
}
export function summarizeExistingConfig(config: ClawdbotConfig): string {