feat: add models scan and fallbacks
This commit is contained in:
@@ -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
14
src/commands/models.ts
Normal 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";
|
||||
89
src/commands/models/aliases.ts
Normal file
89
src/commands/models/aliases.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
134
src/commands/models/fallbacks.ts
Normal file
134
src/commands/models/fallbacks.ts
Normal 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
419
src/commands/models/list.ts
Normal 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
267
src/commands/models/scan.ts
Normal 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]}`);
|
||||
}
|
||||
}
|
||||
29
src/commands/models/set.ts
Normal file
29
src/commands/models/set.ts
Normal 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}`);
|
||||
}
|
||||
95
src/commands/models/shared.ts
Normal file
95
src/commands/models/shared.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user