feat: add models scan and fallbacks

This commit is contained in:
Peter Steinberger
2026-01-04 17:50:55 +01:00
parent a2ba7ddf90
commit 734bb6b4fd
22 changed files with 2058 additions and 187 deletions

View File

@@ -12,6 +12,7 @@ import {
resolveConfiguredModelRef,
resolveThinkingDefault,
} from "../agents/model-selection.js";
import { runWithModelFallback } from "../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import {
@@ -364,6 +365,8 @@ export async function agentCommand(
});
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider;
let fallbackModel = model;
try {
const surface =
opts.surface?.trim().toLowerCase() ||
@@ -372,32 +375,41 @@ export async function agentCommand(
if (!raw) return undefined;
return raw === "imsg" ? "imessage" : raw;
})();
result = await runEmbeddedPiAgent({
sessionId,
sessionKey,
surface,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
const fallbackResult = await runWithModelFallback({
cfg,
provider,
model,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
emitAgentEvent({
run: (providerOverride, modelOverride) =>
runEmbeddedPiAgent({
sessionId,
sessionKey,
surface,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
provider: providerOverride,
model: modelOverride,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
stream: evt.stream,
data: evt.data,
});
},
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => {
emitAgentEvent({
runId,
stream: evt.stream,
data: evt.data,
});
},
}),
});
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
emitAgentEvent({
runId,
stream: "job",
@@ -431,7 +443,10 @@ export async function agentCommand(
// Update token+model fields in the session store.
if (sessionStore && sessionKey) {
const usage = result.meta.agentMeta?.usage;
const modelUsed = result.meta.agentMeta?.model ?? model;
const modelUsed =
result.meta.agentMeta?.model ?? fallbackModel ?? model;
const providerUsed =
result.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
const contextTokens =
agentCfg?.contextTokens ??
lookupContextTokens(modelUsed) ??
@@ -445,6 +460,7 @@ export async function agentCommand(
...entry,
sessionId,
updatedAt: Date.now(),
modelProvider: providerUsed,
model: modelUsed,
contextTokens,
};

14
src/commands/models.ts Normal file
View File

@@ -0,0 +1,14 @@
export { modelsListCommand, modelsStatusCommand } from "./models/list.js";
export {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
modelsFallbacksRemoveCommand,
} from "./models/fallbacks.js";
export { modelsScanCommand } from "./models/scan.js";
export { modelsSetCommand } from "./models/set.js";

View File

@@ -0,0 +1,89 @@
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
ensureFlagCompatibility,
normalizeAlias,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsAliasesListCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const aliases = cfg.agent?.modelAliases ?? {};
if (opts.json) {
runtime.log(JSON.stringify({ aliases }, null, 2));
return;
}
if (opts.plain) {
for (const [alias, target] of Object.entries(aliases)) {
runtime.log(`${alias} ${target}`);
}
return;
}
runtime.log(`Aliases (${Object.keys(aliases).length}):`);
if (Object.keys(aliases).length === 0) {
runtime.log("- none");
return;
}
for (const [alias, target] of Object.entries(aliases)) {
runtime.log(`- ${alias} -> ${target}`);
}
}
export async function modelsAliasesAddCommand(
aliasRaw: string,
modelRaw: string,
runtime: RuntimeEnv,
) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const nextAliases = { ...(cfg.agent?.modelAliases ?? {}) };
nextAliases[alias] = `${resolved.provider}/${resolved.model}`;
return {
...cfg,
agent: {
...cfg.agent,
modelAliases: nextAliases,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`);
}
export async function modelsAliasesRemoveCommand(
aliasRaw: string,
runtime: RuntimeEnv,
) {
const alias = normalizeAlias(aliasRaw);
const updated = await updateConfig((cfg) => {
const nextAliases = { ...(cfg.agent?.modelAliases ?? {}) };
if (!nextAliases[alias]) {
throw new Error(`Alias not found: ${alias}`);
}
delete nextAliases[alias];
return {
...cfg,
agent: {
...cfg.agent,
modelAliases: nextAliases,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (!updated.agent?.modelAliases || Object.keys(updated.agent.modelAliases).length === 0) {
runtime.log("No aliases configured.");
}
}

View File

@@ -0,0 +1,134 @@
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
buildModelAliasIndex,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
DEFAULT_PROVIDER,
ensureFlagCompatibility,
modelKey,
resolveModelTarget,
updateConfig,
} from "./shared.js";
export async function modelsFallbacksListCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const fallbacks = cfg.agent?.modelFallbacks ?? [];
if (opts.json) {
runtime.log(JSON.stringify({ fallbacks }, null, 2));
return;
}
if (opts.plain) {
for (const entry of fallbacks) runtime.log(entry);
return;
}
runtime.log(`Fallbacks (${fallbacks.length}):`);
if (fallbacks.length === 0) {
runtime.log("- none");
return;
}
for (const entry of fallbacks) runtime.log(`- ${entry}`);
}
export async function modelsFallbacksAddCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.modelFallbacks ?? [];
const existingKeys = existing
.map((entry) =>
resolveModelRefFromString({
raw: String(entry ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
}),
)
.filter(Boolean)
.map((entry) => modelKey(entry!.ref.provider, entry!.ref.model));
if (existingKeys.includes(targetKey)) return cfg;
return {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: [...existing, targetKey],
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksRemoveCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const targetKey = modelKey(resolved.provider, resolved.model);
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const existing = cfg.agent?.modelFallbacks ?? [];
const filtered = existing.filter((entry) => {
const resolvedEntry = resolveModelRefFromString({
raw: String(entry ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolvedEntry) return true;
return (
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
targetKey
);
});
if (filtered.length === existing.length) {
throw new Error(`Fallback not found: ${targetKey}`);
}
return {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: filtered,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
}
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
await updateConfig((cfg) => ({
...cfg,
agent: {
...cfg.agent,
modelFallbacks: [],
},
}));
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log("Fallback list cleared.");
}

419
src/commands/models/list.ts Normal file
View File

@@ -0,0 +1,419 @@
import chalk from "chalk";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { getEnvApiKey, type Api, type Model } from "@mariozechner/pi-ai";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { ensureClawdbotModelsJson } from "../../agents/models-config.js";
import {
buildModelAliasIndex,
parseModelRef,
resolveModelRefFromString,
resolveConfiguredModelRef,
} from "../../agents/model-selection.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
type ClawdbotConfig,
} from "../../config/config.js";
import { info } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
ensureFlagCompatibility,
formatTokenK,
modelKey,
} from "./shared.js";
const MODEL_PAD = 42;
const INPUT_PAD = 10;
const CTX_PAD = 8;
const LOCAL_PAD = 5;
const AUTH_PAD = 5;
const isRich = (opts?: { json?: boolean; plain?: boolean }) =>
Boolean(process.stdout.isTTY && chalk.level > 0 && !opts?.json && !opts?.plain);
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
if (value.length <= max) return value;
if (max <= 3) return value.slice(0, max);
return `${value.slice(0, max - 3)}...`;
};
type ConfiguredEntry = {
key: string;
ref: { provider: string; model: string };
tags: Set<string>;
aliases: string[];
};
type ModelRow = {
key: string;
name: string;
input: string;
contextWindow: number | null;
local: boolean | null;
available: boolean | null;
tags: string[];
missing: boolean;
};
const isLocalBaseUrl = (baseUrl: string) => {
try {
const url = new URL(baseUrl);
const host = url.hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "0.0.0.0" ||
host === "::1" ||
host.endsWith(".local")
);
} catch {
return false;
}
};
const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const order: string[] = [];
const tagsByKey = new Map<string, Set<string>>();
const aliasesByKey = new Map<string, string[]>();
for (const [key, aliases] of aliasIndex.byKey.entries()) {
aliasesByKey.set(key, aliases);
}
const addEntry = (ref: { provider: string; model: string }, tag: string) => {
const key = modelKey(ref.provider, ref.model);
if (!tagsByKey.has(key)) {
tagsByKey.set(key, new Set());
order.push(key);
}
tagsByKey.get(key)?.add(tag);
};
addEntry(resolvedDefault, "default");
(cfg.agent?.modelFallbacks ?? []).forEach((raw, idx) => {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) return;
addEntry(resolved.ref, `fallback#${idx + 1}`);
});
(cfg.agent?.allowedModels ?? []).forEach((raw) => {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) return;
addEntry(parsed, "allowed");
});
for (const targetRaw of Object.values(cfg.agent?.modelAliases ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(targetRaw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) continue;
addEntry(resolved.ref, "alias");
}
const entries: ConfiguredEntry[] = order.map((key) => {
const slash = key.indexOf("/");
const provider = slash === -1 ? key : key.slice(0, slash);
const model = slash === -1 ? "" : key.slice(slash + 1);
return {
key,
ref: { provider, model },
tags: tagsByKey.get(key) ?? new Set(),
aliases: aliasesByKey.get(key) ?? [],
} satisfies ConfiguredEntry;
});
return { entries };
};
async function loadModelRegistry(cfg: ClawdbotConfig) {
await ensureClawdbotModelsJson(cfg);
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);
const models = registry.getAll() as Model<Api>[];
const availableModels = registry.getAvailable() as Model<Api>[];
const availableKeys = new Set(
availableModels.map((model) => modelKey(model.provider, model.id)),
);
return { registry, models, availableKeys };
}
function toModelRow(params: {
model?: Model<Api>;
key: string;
tags: string[];
aliases?: string[];
availableKeys?: Set<string>;
}): ModelRow {
const { model, key, tags, aliases = [], availableKeys } = params;
if (!model) {
return {
key,
name: key,
input: "-",
contextWindow: null,
local: null,
available: null,
tags: [...tags, "missing"],
missing: true,
};
}
const input = model.input.join("+") || "text";
const local = isLocalBaseUrl(model.baseUrl);
const envKey = getEnvApiKey(model.provider);
const available =
availableKeys?.has(modelKey(model.provider, model.id)) || Boolean(envKey);
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
const mergedTags = new Set(tags);
if (aliasTags.length > 0) {
for (const tag of mergedTags) {
if (tag === "alias" || tag.startsWith("alias:")) mergedTags.delete(tag);
}
for (const tag of aliasTags) mergedTags.add(tag);
}
return {
key,
name: model.name || model.id,
input,
contextWindow: model.contextWindow ?? null,
local,
available,
tags: Array.from(mergedTags),
missing: false,
};
}
function printModelTable(
rows: ModelRow[],
runtime: RuntimeEnv,
opts: { json?: boolean; plain?: boolean } = {},
) {
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: rows.length,
models: rows,
},
null,
2,
),
);
return;
}
if (opts.plain) {
for (const row of rows) runtime.log(row.key);
return;
}
const rich = isRich(opts);
const header = [
pad("Model", MODEL_PAD),
pad("Input", INPUT_PAD),
pad("Ctx", CTX_PAD),
pad("Local", LOCAL_PAD),
pad("Auth", AUTH_PAD),
"Tags",
].join(" ");
runtime.log(rich ? chalk.bold(header) : header);
for (const row of rows) {
const keyLabel = pad(truncate(row.key, MODEL_PAD), MODEL_PAD);
const inputLabel = pad(row.input || "-", INPUT_PAD);
const ctxLabel = pad(formatTokenK(row.contextWindow), CTX_PAD);
const localLabel = pad(
row.local === null ? "-" : row.local ? "yes" : "no",
LOCAL_PAD,
);
const authLabel = pad(
row.available === null ? "-" : row.available ? "yes" : "no",
AUTH_PAD,
);
const tagsLabel = row.tags.length > 0 ? row.tags.join(",") : "";
const line = [
rich ? chalk.cyan(keyLabel) : keyLabel,
inputLabel,
ctxLabel,
localLabel,
authLabel,
rich ? chalk.gray(tagsLabel) : tagsLabel,
].join(" ");
runtime.log(line);
}
}
export async function modelsListCommand(
opts: {
all?: boolean;
local?: boolean;
provider?: string;
json?: boolean;
plain?: boolean;
},
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const providerFilter = opts.provider?.trim().toLowerCase();
let models: Model<Api>[] = [];
let availableKeys: Set<string> | undefined;
try {
const loaded = await loadModelRegistry(cfg);
models = loaded.models;
availableKeys = loaded.availableKeys;
} catch (err) {
runtime.error(`Model registry unavailable: ${String(err)}`);
}
const modelByKey = new Map(
models.map((model) => [modelKey(model.provider, model.id), model]),
);
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const rows: ModelRow[] = [];
if (opts.all) {
const sorted = [...models].sort((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) return p;
return a.id.localeCompare(b.id);
});
for (const model of sorted) {
if (providerFilter && model.provider.toLowerCase() !== providerFilter) {
continue;
}
if (opts.local && !isLocalBaseUrl(model.baseUrl)) continue;
const key = modelKey(model.provider, model.id);
const configured = configuredByKey.get(key);
rows.push(
toModelRow({
model,
key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys,
}),
);
}
} else {
for (const entry of entries) {
if (
providerFilter &&
entry.ref.provider.toLowerCase() !== providerFilter
) {
continue;
}
const model = modelByKey.get(entry.key);
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) continue;
if (opts.local && !model) continue;
rows.push(
toModelRow({
model,
key: entry.key,
tags: Array.from(entry.tags),
aliases: entry.aliases,
availableKeys,
}),
);
}
}
if (rows.length === 0) {
runtime.log("No models found.");
return;
}
printModelTable(rows, runtime, opts);
}
export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const rawModel = cfg.agent?.model?.trim() ?? "";
const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`;
const fallbacks = cfg.agent?.modelFallbacks ?? [];
const aliases = cfg.agent?.modelAliases ?? {};
const allowed = cfg.agent?.allowedModels ?? [];
if (opts.json) {
runtime.log(
JSON.stringify(
{
configPath: CONFIG_PATH_CLAWDBOT,
defaultModel: defaultLabel,
resolvedDefault: `${resolved.provider}/${resolved.model}`,
fallbacks,
aliases,
allowed,
},
null,
2,
),
);
return;
}
if (opts.plain) {
runtime.log(defaultLabel);
return;
}
runtime.log(info(`Config: ${CONFIG_PATH_CLAWDBOT}`));
runtime.log(`Default: ${defaultLabel}`);
runtime.log(
`Fallbacks (${fallbacks.length || 0}): ${fallbacks.join(", ") || "-"}`,
);
runtime.log(
`Aliases (${Object.keys(aliases).length || 0}): ${
Object.keys(aliases).length
? Object.entries(aliases)
.map(([alias, target]) => `${alias} -> ${target}`)
.join(", ")
: "-"
}`,
);
runtime.log(
`Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`,
);
}

267
src/commands/models/scan.ts Normal file
View File

@@ -0,0 +1,267 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
scanOpenRouterModels,
type ModelScanResult,
} from "../../agents/model-scan.js";
import { warn } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
buildAllowlistSet,
formatMs,
formatTokenK,
updateConfig,
} from "./shared.js";
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
const MODEL_PAD = 42;
const CTX_PAD = 8;
const pad = (value: string, size: number) => value.padEnd(size);
const truncate = (value: string, max: number) => {
if (value.length <= max) return value;
if (max <= 3) return value.slice(0, max);
return `${value.slice(0, max - 3)}...`;
};
function sortScanResults(results: ModelScanResult[]): ModelScanResult[] {
return results.slice().sort((a, b) => {
const aImage = a.image.ok ? 1 : 0;
const bImage = b.image.ok ? 1 : 0;
if (aImage !== bImage) return bImage - aImage;
const aToolLatency = a.tool.latencyMs ?? Number.POSITIVE_INFINITY;
const bToolLatency = b.tool.latencyMs ?? Number.POSITIVE_INFINITY;
if (aToolLatency !== bToolLatency) return aToolLatency - bToolLatency;
const aCtx = a.contextLength ?? 0;
const bCtx = b.contextLength ?? 0;
if (aCtx !== bCtx) return bCtx - aCtx;
const aParams = a.inferredParamB ?? 0;
const bParams = b.inferredParamB ?? 0;
if (aParams !== bParams) return bParams - aParams;
return a.modelRef.localeCompare(b.modelRef);
});
}
function buildScanHint(result: ModelScanResult): string {
const toolLabel = result.tool.ok
? `tool ${formatMs(result.tool.latencyMs)}`
: "tool fail";
const imageLabel = result.image.skipped
? "img skip"
: result.image.ok
? `img ${formatMs(result.image.latencyMs)}`
: "img fail";
const ctxLabel = result.contextLength
? `ctx ${formatTokenK(result.contextLength)}`
: "ctx ?";
const paramLabel = result.inferredParamB ? `${result.inferredParamB}b` : null;
return [toolLabel, imageLabel, ctxLabel, paramLabel]
.filter(Boolean)
.join(" | ");
}
function printScanSummary(results: ModelScanResult[], runtime: RuntimeEnv) {
const toolOk = results.filter((r) => r.tool.ok);
const imageOk = results.filter((r) => r.image.ok);
const toolImageOk = results.filter((r) => r.tool.ok && r.image.ok);
runtime.log(
`Scan results: tested ${results.length}, tool ok ${toolOk.length}, image ok ${imageOk.length}, tool+image ok ${toolImageOk.length}`,
);
}
function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) {
const header = [
pad("Model", MODEL_PAD),
pad("Tool", 10),
pad("Image", 10),
pad("Ctx", CTX_PAD),
pad("Params", 8),
"Notes",
].join(" ");
runtime.log(header);
for (const entry of results) {
const modelLabel = pad(truncate(entry.modelRef, MODEL_PAD), MODEL_PAD);
const toolLabel = pad(
entry.tool.ok ? formatMs(entry.tool.latencyMs) : "fail",
10,
);
const imageLabel = pad(
entry.image.ok
? formatMs(entry.image.latencyMs)
: entry.image.skipped
? "skip"
: "fail",
10,
);
const ctxLabel = pad(formatTokenK(entry.contextLength), CTX_PAD);
const paramsLabel = pad(
entry.inferredParamB ? `${entry.inferredParamB}b` : "-",
8,
);
const notes = entry.modality ? `modality:${entry.modality}` : "";
runtime.log(
[modelLabel, toolLabel, imageLabel, ctxLabel, paramsLabel, notes].join(
" ",
),
);
}
}
export async function modelsScanCommand(
opts: {
minParams?: string;
maxAgeDays?: string;
provider?: string;
maxCandidates?: string;
timeout?: string;
concurrency?: string;
yes?: boolean;
input?: boolean;
setDefault?: boolean;
json?: boolean;
},
runtime: RuntimeEnv,
) {
const minParams = opts.minParams ? Number(opts.minParams) : undefined;
if (minParams !== undefined && (!Number.isFinite(minParams) || minParams < 0)) {
throw new Error("--min-params must be >= 0");
}
const maxAgeDays = opts.maxAgeDays ? Number(opts.maxAgeDays) : undefined;
if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) {
throw new Error("--max-age-days must be >= 0");
}
const maxCandidates = opts.maxCandidates
? Number(opts.maxCandidates)
: 6;
if (!Number.isFinite(maxCandidates) || maxCandidates <= 0) {
throw new Error("--max-candidates must be > 0");
}
const timeout = opts.timeout ? Number(opts.timeout) : undefined;
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new Error("--timeout must be > 0");
}
const concurrency = opts.concurrency ? Number(opts.concurrency) : undefined;
if (concurrency !== undefined && (!Number.isFinite(concurrency) || concurrency <= 0)) {
throw new Error("--concurrency must be > 0");
}
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
const storedKey = await authStorage.getApiKey("openrouter");
const results = await scanOpenRouterModels({
apiKey: storedKey ?? undefined,
minParamB: minParams,
maxAgeDays,
providerFilter: opts.provider,
timeoutMs: timeout,
concurrency,
});
const toolOk = results.filter((entry) => entry.tool.ok);
if (toolOk.length === 0) {
throw new Error("No tool-capable OpenRouter free models found.");
}
const sorted = sortScanResults(toolOk);
const imagePreferred = sorted.filter((entry) => entry.image.ok);
const preselectPool = imagePreferred.length > 0 ? imagePreferred : sorted;
const preselected = preselectPool
.slice(0, Math.floor(maxCandidates))
.map((entry) => entry.modelRef);
if (!opts.json) {
printScanSummary(results, runtime);
printScanTable(sorted, runtime);
}
const noInput = opts.input === false;
const canPrompt = process.stdin.isTTY && !opts.yes && !noInput && !opts.json;
let selected: string[] = preselected;
if (canPrompt) {
const selection = await multiselect({
message: "Select fallback models (ordered)",
options: sorted.map((entry) => ({
value: entry.modelRef,
label: entry.modelRef,
hint: buildScanHint(entry),
})),
initialValues: preselected,
});
if (isCancel(selection)) {
cancel("Model scan cancelled.");
runtime.exit(0);
}
selected = selection as string[];
} else if (!process.stdin.isTTY && !opts.yes && !noInput && !opts.json) {
throw new Error("Non-interactive scan: pass --yes to apply defaults.");
}
if (selected.length === 0) {
throw new Error("No models selected for fallbacks.");
}
const updated = await updateConfig((cfg) => {
const next = {
...cfg,
agent: {
...cfg.agent,
modelFallbacks: selected,
...(opts.setDefault ? { model: selected[0] } : {}),
},
};
return next;
});
const allowlist = buildAllowlistSet(updated);
const allowlistMissing =
allowlist.size > 0
? selected.filter((entry) => !allowlist.has(entry))
: [];
if (opts.json) {
runtime.log(
JSON.stringify(
{
selected,
setDefault: Boolean(opts.setDefault),
results,
warnings:
allowlistMissing.length > 0
? [
`Selected models not in agent.allowedModels: ${allowlistMissing.join(", ")}`,
]
: [],
},
null,
2,
),
);
return;
}
if (allowlistMissing.length > 0) {
runtime.log(
warn(
`Warning: ${allowlistMissing.length} selected models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissing.join(", ")}`,
),
);
}
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${selected.join(", ")}`);
if (opts.setDefault) {
runtime.log(`Default model: ${selected[0]}`);
}
}

View File

@@ -0,0 +1,29 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import { buildAllowlistSet, modelKey, resolveModelTarget, updateConfig } from "./shared.js";
export async function modelsSetCommand(
modelRaw: string,
runtime: RuntimeEnv,
) {
const updated = await updateConfig((cfg) => {
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
const allowlist = buildAllowlistSet(cfg);
if (allowlist.size > 0) {
const key = modelKey(resolved.provider, resolved.model);
if (!allowlist.has(key)) {
throw new Error(`Model ${key} is not in agent.allowedModels.`);
}
}
return {
...cfg,
agent: {
...cfg.agent,
model: `${resolved.provider}/${resolved.model}`,
},
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Default model: ${updated.agent?.model ?? modelRaw}`);
}

View File

@@ -0,0 +1,95 @@
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
readConfigFileSnapshot,
writeConfigFile,
type ClawdbotConfig,
} from "../../config/config.js";
export const ensureFlagCompatibility = (opts: {
json?: boolean;
plain?: boolean;
}) => {
if (opts.json && opts.plain) {
throw new Error("Choose either --json or --plain, not both.");
}
};
export const formatTokenK = (value?: number | null) => {
if (!value || !Number.isFinite(value)) return "-";
if (value < 1024) return `${Math.round(value)}`;
return `${Math.round(value / 1024)}k`;
};
export const formatMs = (value?: number | null) => {
if (value === null || value === undefined) return "-";
if (!Number.isFinite(value)) return "-";
if (value < 1000) return `${Math.round(value)}ms`;
return `${Math.round(value / 100) / 10}s`;
};
export async function updateConfig(
mutator: (cfg: ClawdbotConfig) => ClawdbotConfig,
): Promise<ClawdbotConfig> {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
const issues = snapshot.issues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
}
const next = mutator(snapshot.config);
await writeConfigFile(next);
return next;
}
export function resolveModelTarget(params: {
raw: string;
cfg: ClawdbotConfig;
}): { provider: string; model: string } {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const resolved = resolveModelRefFromString({
raw: params.raw,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) {
throw new Error(`Invalid model reference: ${params.raw}`);
}
return resolved.ref;
}
export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> {
const allowed = new Set<string>();
for (const raw of cfg.agent?.allowedModels ?? []) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
allowed.add(modelKey(parsed.provider, parsed.model));
}
return allowed;
}
export function normalizeAlias(alias: string): string {
const trimmed = alias.trim();
if (!trimmed) throw new Error("Alias cannot be empty.");
if (!/^[A-Za-z0-9_.:-]+$/.test(trimmed)) {
throw new Error(
"Alias must use letters, numbers, dots, underscores, colons, or dashes.",
);
}
return trimmed;
}
export { modelKey };
export { DEFAULT_MODEL, DEFAULT_PROVIDER };