Files
clawdbot/src/auto-reply/reply/directive-handling.model.ts

361 lines
11 KiB
TypeScript

import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js";
import {
type ModelAliasIndex,
modelKey,
normalizeProviderId,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { shortenHomePath } from "../../utils.js";
import type { ReplyPayload } from "../types.js";
import {
formatAuthLabel,
type ModelAuthDetailMode,
resolveAuthLabel,
resolveProfileOverride,
} from "./directive-handling.auth.js";
import {
type ModelPickerCatalogEntry,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
function buildModelPickerCatalog(params: {
cfg: ClawdbotConfig;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
}): ModelPickerCatalogEntry[] {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
});
const buildConfiguredCatalog = (): ModelPickerCatalogEntry[] => {
const out: ModelPickerCatalogEntry[] = [];
const keys = new Set<string>();
const pushRef = (ref: { provider: string; model: string }, name?: string) => {
const provider = normalizeProviderId(ref.provider);
const id = String(ref.model ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: name ?? id });
};
const pushRaw = (raw?: string) => {
const value = String(raw ?? "").trim();
if (!value) return;
const resolved = resolveModelRefFromString({
raw: value,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (!resolved) return;
pushRef(resolved.ref);
};
pushRef(resolvedDefault);
const modelConfig = params.cfg.agents?.defaults?.model;
const modelFallbacks =
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
for (const fallback of modelFallbacks) {
pushRaw(String(fallback ?? ""));
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (imageConfig && typeof imageConfig === "object") {
pushRaw(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
pushRaw(String(fallback ?? ""));
}
}
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
pushRaw(raw);
}
return out;
};
const keys = new Set<string>();
const out: ModelPickerCatalogEntry[] = [];
const push = (entry: ModelPickerCatalogEntry) => {
const provider = normalizeProviderId(entry.provider);
const id = String(entry.id ?? "").trim();
if (!provider || !id) return;
const key = modelKey(provider, id);
if (keys.has(key)) return;
keys.add(key);
out.push({ provider, id, name: entry.name });
};
const hasAllowlist = Object.keys(params.cfg.agents?.defaults?.models ?? {}).length > 0;
if (!hasAllowlist) {
for (const entry of params.allowedModelCatalog) {
push({
provider: entry.provider,
id: entry.id ?? "",
name: entry.name,
});
}
for (const entry of buildConfiguredCatalog()) {
push(entry);
}
return out;
}
// Prefer catalog entries (when available), but always merge in config-only
// allowlist entries. This keeps custom providers/models visible in /model.
for (const entry of params.allowedModelCatalog) {
push({
provider: entry.provider,
id: entry.id ?? "",
name: entry.name,
});
}
// Merge any configured allowlist keys that the catalog doesn't know about.
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (!resolved) continue;
push({
provider: resolved.ref.provider,
id: resolved.ref.model,
name: resolved.ref.model,
});
}
// Ensure the configured default is always present (even when no allowlist).
if (resolvedDefault.model) {
push({
provider: resolvedDefault.provider,
id: resolvedDefault.model,
name: resolvedDefault.model,
});
}
return out;
}
export async function maybeHandleModelDirectiveInfo(params: {
directives: InlineDirectives;
cfg: ClawdbotConfig;
agentDir: string;
activeAgentId: string;
provider: string;
model: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
resetModelOverride: boolean;
}): Promise<ReplyPayload | undefined> {
if (!params.directives.hasModelDirective) return undefined;
const rawDirective = params.directives.rawModelDirective?.trim();
const directive = rawDirective?.toLowerCase();
const wantsStatus = directive === "status";
const wantsSummary = !rawDirective;
const wantsLegacyList = directive === "list";
if (!wantsSummary && !wantsStatus && !wantsLegacyList) return undefined;
if (params.directives.rawModelProfile) {
return { text: "Auth profile override requires a model selection." };
}
const pickerCatalog = buildModelPickerCatalog({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelCatalog: params.allowedModelCatalog,
});
if (wantsLegacyList) {
const reply = await resolveModelsCommandReply({
cfg: params.cfg,
commandBodyNormalized: "/models",
});
return reply ?? { text: "No models available." };
}
if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
return {
text: [
`Current: ${current}`,
"",
"Switch: /model <provider/model>",
"Browse: /models (providers) or /models <provider> (models)",
"More: /model status",
].join("\n"),
};
}
const modelsPath = `${params.agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authMode: ModelAuthDetailMode = "verbose";
if (pickerCatalog.length === 0) return { text: "No models available." };
const authByProvider = new Map<string, string>();
for (const entry of pickerCatalog) {
const provider = normalizeProviderId(entry.provider);
if (authByProvider.has(provider)) continue;
const auth = await resolveAuthLabel(
provider,
params.cfg,
modelsPath,
params.agentDir,
authMode,
);
authByProvider.set(provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${params.defaultProvider}/${params.defaultModel}`;
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Agent: ${params.activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(params.agentDir))}`,
];
if (params.resetModelOverride) {
lines.push(`(previous selection reset to default)`);
}
const byProvider = new Map<string, ModelPickerCatalogEntry[]>();
for (const entry of pickerCatalog) {
const provider = normalizeProviderId(entry.provider);
const models = byProvider.get(provider);
if (models) {
models.push(entry);
continue;
}
byProvider.set(provider, [entry]);
}
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}]${endpointSuffix}${apiSuffix} auth: ${authLabel}`);
for (const entry of models) {
const label = `${provider}/${entry.id}`;
const aliases = params.aliasIndex.byKey.get(label);
const aliasSuffix = aliases && aliases.length > 0 ? ` (${aliases.join(", ")})` : "";
lines.push(`${label}${aliasSuffix}`);
}
}
return { text: lines.join("\n") };
}
export function resolveModelSelectionFromDirective(params: {
directives: InlineDirectives;
cfg: ClawdbotConfig;
agentDir: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
provider: string;
}): {
modelSelection?: ModelDirectiveSelection;
profileOverride?: string;
errorText?: string;
} {
if (!params.directives.hasModelDirective || !params.directives.rawModelDirective) {
if (params.directives.rawModelProfile) {
return { errorText: "Auth profile override requires a model selection." };
}
return {};
}
const raw = params.directives.rawModelDirective.trim();
let modelSelection: ModelDirectiveSelection | undefined;
if (/^[0-9]+$/.test(raw)) {
return {
errorText: [
"Numeric model selection is not supported in chat.",
"",
"Browse: /models or /models <provider>",
"Switch: /model <provider/model>",
].join("\n"),
};
}
const explicit = resolveModelRefFromString({
raw,
defaultProvider: params.defaultProvider,
aliasIndex: params.aliasIndex,
});
if (explicit) {
const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model);
if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) {
modelSelection = {
provider: explicit.ref.provider,
model: explicit.ref.model,
isDefault:
explicit.ref.provider === params.defaultProvider &&
explicit.ref.model === params.defaultModel,
...(explicit.alias ? { alias: explicit.alias } : {}),
};
}
}
if (!modelSelection) {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys,
});
if (resolved.error) {
return { errorText: resolved.error };
}
if (resolved.selection) {
modelSelection = resolved.selection;
}
}
let profileOverride: string | undefined;
if (modelSelection && params.directives.rawModelProfile) {
const profileResolved = resolveProfileOverride({
rawProfile: params.directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir: params.agentDir,
});
if (profileResolved.error) {
return { errorText: profileResolved.error };
}
profileOverride = profileResolved.profileId;
}
return { modelSelection, profileOverride };
}