feat(model): add /model picker
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user