feat(model): add /model picker
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
### Changes
|
### Changes
|
||||||
- CLI: simplify configure section selection (single-select with optional add-more).
|
- 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.
|
- 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 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: 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.
|
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
|||||||
|
|
||||||
## Configuration options
|
## Configuration options
|
||||||
|
|
||||||
- `models.providers.minimax.baseUrl`: `https://api.minimax.io/v1` or `https://api.minimax.io/anthropic`.
|
- `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`: `openai-completions` (cloud) or `anthropic-messages` (API).
|
- `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.apiKey`: MiniMax API key (`MINIMAX_API_KEY`).
|
||||||
- `models.providers.minimax.models`: define `id`, `name`, `reasoning`, `contextWindow`, `maxTokens`, `cost`.
|
- `models.providers.minimax.models`: define `id`, `name`, `reasoning`, `contextWindow`, `maxTokens`, `cost`.
|
||||||
- `agents.defaults.models`: alias models you want in the allowlist.
|
- `agents.defaults.models`: alias models you want in the allowlist.
|
||||||
|
|||||||
@@ -496,6 +496,12 @@ Use the `/model` command as a standalone message:
|
|||||||
|
|
||||||
You can list available models with `/model`, `/model list`, or `/model status`.
|
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):
|
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.
|
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?
|
### Why do I see “Model … is not allowed” and then no reply?
|
||||||
|
|
||||||
|
|||||||
@@ -611,12 +611,12 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
models: {
|
models: {
|
||||||
providers: {
|
providers: {
|
||||||
minimax: {
|
minimax: {
|
||||||
baseUrl: "https://api.minimax.io/v1",
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
api: "openai-completions",
|
api: "anthropic-messages",
|
||||||
apiKey: "sk-minimax-test",
|
apiKey: "sk-minimax-test",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "minimax-m2.1",
|
id: "MiniMax-M2.1",
|
||||||
name: "MiniMax M2.1",
|
name: "MiniMax M2.1",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
|
|||||||
@@ -1449,9 +1449,9 @@ describe("directive behavior", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
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).not.toContain("claude-sonnet-4-1");
|
||||||
expect(text).toContain("auth:");
|
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1512,9 +1512,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
expect(text).toContain("claude-opus-4-5 — anthropic");
|
||||||
expect(text).toContain("auth:");
|
expect(text).toContain("gpt-4.1-mini — openai");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1544,9 +1544,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Model catalog unavailable");
|
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("claude-opus-4-5 — anthropic");
|
||||||
expect(text).toContain("openai/gpt-4.1-mini");
|
expect(text).toContain("gpt-4.1-mini — openai");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1574,7 +1574,6 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("auth:");
|
|
||||||
expect(text).not.toContain("missing (missing)");
|
expect(text).not.toContain("missing (missing)");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,30 @@ const usageMocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
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 { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
abortEmbeddedPiRun,
|
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 () => {
|
it("rejects /restart by default", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
|
|||||||
@@ -316,6 +316,121 @@ const resolveProfileOverride = (params: {
|
|||||||
return { profileId: raw };
|
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 = {
|
export type InlineDirectives = {
|
||||||
cleaned: string;
|
cleaned: string;
|
||||||
hasThinkDirective: boolean;
|
hasThinkDirective: boolean;
|
||||||
@@ -527,111 +642,90 @@ export async function handleDirectiveOnly(params: {
|
|||||||
directives.hasElevatedDirective && !runtimeIsSandboxed;
|
directives.hasElevatedDirective && !runtimeIsSandboxed;
|
||||||
|
|
||||||
if (directives.hasModelDirective) {
|
if (directives.hasModelDirective) {
|
||||||
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
|
const rawDirective = directives.rawModelDirective?.trim();
|
||||||
const isModelListAlias =
|
const directive = rawDirective?.toLowerCase();
|
||||||
modelDirective === "status" || modelDirective === "list";
|
const wantsStatus = directive === "status";
|
||||||
if (!directives.rawModelDirective || isModelListAlias) {
|
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 modelsPath = `${agentDir}/models.json`;
|
||||||
const formatPath = (value: string) => shortenHomePath(value);
|
const formatPath = (value: string) => shortenHomePath(value);
|
||||||
const authMode: ModelAuthDetailMode =
|
const authMode: ModelAuthDetailMode = "verbose";
|
||||||
modelDirective === "status" ? "verbose" : "compact";
|
const catalog = pickerCatalog;
|
||||||
if (allowedModelCatalog.length === 0) {
|
if (catalog.length === 0) return { text: "No models available." };
|
||||||
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 authByProvider = new Map<string, string>();
|
const authByProvider = new Map<string, string>();
|
||||||
for (const entry of allowedModelCatalog) {
|
for (const entry of catalog) {
|
||||||
if (authByProvider.has(entry.provider)) continue;
|
const provider = normalizeProviderId(entry.provider);
|
||||||
|
if (authByProvider.has(provider)) continue;
|
||||||
const auth = await resolveAuthLabel(
|
const auth = await resolveAuthLabel(
|
||||||
entry.provider,
|
provider,
|
||||||
params.cfg,
|
params.cfg,
|
||||||
modelsPath,
|
modelsPath,
|
||||||
agentDir,
|
agentDir,
|
||||||
authMode,
|
authMode,
|
||||||
);
|
);
|
||||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
authByProvider.set(provider, formatAuthLabel(auth));
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = `${params.provider}/${params.model}`;
|
const current = `${params.provider}/${params.model}`;
|
||||||
const defaultLabel = `${defaultProvider}/${defaultModel}`;
|
const defaultLabel = `${defaultProvider}/${defaultModel}`;
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -644,26 +738,32 @@ export async function handleDirectiveOnly(params: {
|
|||||||
lines.push(`(previous selection reset to default)`);
|
lines.push(`(previous selection reset to default)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group models by provider
|
const byProvider = new Map<string, ModelPickerCatalogEntry[]>();
|
||||||
const byProvider = new Map<string, typeof allowedModelCatalog>();
|
for (const entry of catalog) {
|
||||||
for (const entry of allowedModelCatalog) {
|
const provider = normalizeProviderId(entry.provider);
|
||||||
const models = byProvider.get(entry.provider);
|
const models = byProvider.get(provider);
|
||||||
if (models) {
|
if (models) {
|
||||||
models.push(entry);
|
models.push(entry);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byProvider.set(entry.provider, [entry]);
|
byProvider.set(provider, [entry]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate over provider groups
|
|
||||||
for (const provider of byProvider.keys()) {
|
for (const provider of byProvider.keys()) {
|
||||||
const models = byProvider.get(provider);
|
const models = byProvider.get(provider);
|
||||||
if (!models) continue;
|
if (!models) continue;
|
||||||
const authLabel = authByProvider.get(provider) ?? "missing";
|
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("");
|
||||||
lines.push(`[${provider}] auth: ${authLabel}`);
|
lines.push(
|
||||||
|
`[${provider}]${endpointSuffix}${apiSuffix} auth: ${authLabel}`,
|
||||||
|
);
|
||||||
for (const entry of models) {
|
for (const entry of models) {
|
||||||
const label = `${entry.provider}/${entry.id}`;
|
const label = `${provider}/${entry.id}`;
|
||||||
const aliases = aliasIndex.byKey.get(label);
|
const aliases = aliasIndex.byKey.get(label);
|
||||||
const aliasSuffix =
|
const aliasSuffix =
|
||||||
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
|
aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
|
||||||
@@ -672,9 +772,6 @@ export async function handleDirectiveOnly(params: {
|
|||||||
}
|
}
|
||||||
return { text: lines.join("\n") };
|
return { text: lines.join("\n") };
|
||||||
}
|
}
|
||||||
if (directives.rawModelProfile && !modelDirective) {
|
|
||||||
throw new Error("Auth profile override requires a model selection.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directives.hasThinkDirective && !directives.thinkLevel) {
|
if (directives.hasThinkDirective && !directives.thinkLevel) {
|
||||||
@@ -835,17 +932,87 @@ export async function handleDirectiveOnly(params: {
|
|||||||
let modelSelection: ModelDirectiveSelection | undefined;
|
let modelSelection: ModelDirectiveSelection | undefined;
|
||||||
let profileOverride: string | undefined;
|
let profileOverride: string | undefined;
|
||||||
if (directives.hasModelDirective && directives.rawModelDirective) {
|
if (directives.hasModelDirective && directives.rawModelDirective) {
|
||||||
const resolved = resolveModelDirectiveSelection({
|
const raw = directives.rawModelDirective.trim();
|
||||||
raw: directives.rawModelDirective,
|
if (/^[0-9]+$/.test(raw)) {
|
||||||
defaultProvider,
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
defaultModel,
|
cfg: params.cfg,
|
||||||
aliasIndex,
|
defaultProvider,
|
||||||
allowedModelKeys,
|
defaultModel,
|
||||||
});
|
});
|
||||||
if (resolved.error) {
|
const pickerCatalog: ModelPickerCatalogEntry[] = (() => {
|
||||||
return { text: resolved.error };
|
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 (modelSelection) {
|
||||||
if (directives.rawModelProfile) {
|
if (directives.rawModelProfile) {
|
||||||
const profileResolved = resolveProfileOverride({
|
const profileResolved = resolveProfileOverride({
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export async function promptAuthChoiceGrouped(params: {
|
|||||||
|
|
||||||
const methodSelection = (await params.prompter.select({
|
const methodSelection = (await params.prompter.select({
|
||||||
message: `${group.label} auth method`,
|
message: `${group.label} auth method`,
|
||||||
options: [
|
options: [...group.options, { value: BACK_VALUE, label: "Back" }],
|
||||||
...group.options,
|
|
||||||
{ value: BACK_VALUE, label: "Back" },
|
|
||||||
],
|
|
||||||
})) as string;
|
})) as string;
|
||||||
|
|
||||||
if (methodSelection === BACK_VALUE) {
|
if (methodSelection === BACK_VALUE) {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ describe("applyAuthChoice", () => {
|
|||||||
expect(text).toHaveBeenCalledWith(
|
expect(text).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: "Enter MiniMax API key" }),
|
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({
|
expect(result.config.auth?.profiles?.["minimax:default"]).toMatchObject({
|
||||||
provider: "minimax",
|
provider: "minimax",
|
||||||
mode: "api_key",
|
mode: "api_key",
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ import type {
|
|||||||
ResetScope,
|
ResetScope,
|
||||||
} from "./onboard-types.js";
|
} 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)) {
|
if (isCancel(value)) {
|
||||||
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
|
cancel(stylePromptTitle("Setup cancelled.") ?? "Setup cancelled.");
|
||||||
runtime.exit(0);
|
runtime.exit(0);
|
||||||
}
|
}
|
||||||
return value;
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeExistingConfig(config: ClawdbotConfig): string {
|
export function summarizeExistingConfig(config: ClawdbotConfig): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user