Files
clawdbot/src/auto-reply/reply/directive-handling.model.ts
Jake 634a429c50 fix(model-picker): list each provider/model combo separately (#970)
* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

Co-authored-by: Keith the Silly Goose <keith@42bolton.macnet.nz>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 22:20:11 +00:00

277 lines
9.1 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 {
buildModelPickerItems,
type ModelPickerCatalogEntry,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.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 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 });
};
// 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 wantsList = !rawDirective || directive === "list";
if (!wantsList && !wantsStatus) 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 (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.provider}/${item.model}`);
}
lines.push("", "More: /model status");
return { text: lines.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)) {
const pickerCatalog = buildModelPickerCatalog({
cfg: params.cfg,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelCatalog: params.allowedModelCatalog,
});
const items = buildModelPickerItems(pickerCatalog);
const index = Number.parseInt(raw, 10) - 1;
const item = Number.isFinite(index) ? items[index] : undefined;
if (!item) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${item.provider}/${item.model}`;
const aliases = params.aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: item.provider,
model: item.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {
const resolved = resolveModelDirectiveSelection({
raw,
defaultProvider: params.defaultProvider,
defaultModel: params.defaultModel,
aliasIndex: params.aliasIndex,
allowedModelKeys: params.allowedModelKeys,
});
if (resolved.error) {
return { errorText: resolved.error };
}
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 };
}