Telegram: add inline button model selection for /models and /model commands

This commit is contained in:
Ermenegildo Fiorito
2026-02-03 19:03:00 +01:00
committed by Ayaan Zaidi
parent efb4a34be4
commit 16349b6e93
8 changed files with 757 additions and 86 deletions

View File

@@ -10,10 +10,112 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
buildModelsKeyboard,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
type ProviderInfo,
} from "../../telegram/model-buttons.js";
const PAGE_SIZE_DEFAULT = 20;
const PAGE_SIZE_MAX = 100;
export type ModelsProviderData = {
byProvider: Map<string, Set<string>>;
providers: string[];
resolvedDefault: { provider: string; model: string };
};
/**
* Build provider/model data from config and catalog.
* Exported for reuse by callback handlers.
*/
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: cfg });
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolvedDefault.provider,
});
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) {
return;
}
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
return;
}
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
addRawModelRef(raw);
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].toSorted();
return { byProvider, providers, resolvedDefault };
}
function formatProviderLine(params: { provider: string; count: number }): string {
return `- ${params.provider} (${params.count})`;
}
@@ -78,6 +180,8 @@ function parseModelsArgs(raw: string): {
export async function resolveModelsCommandReply(params: {
cfg: OpenClawConfig;
commandBodyNormalized: string;
surface?: string;
currentModel?: string;
}): Promise<ReplyPayload | null> {
const body = params.commandBodyNormalized.trim();
if (!body.startsWith("/models")) {
@@ -87,88 +191,26 @@ export async function resolveModelsCommandReply(params: {
const argText = body.replace(/^\/models\b/i, "").trim();
const { provider, page, pageSize, all } = parseModelsArgs(argText);
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: params.cfg });
const allowed = buildAllowedModelSet({
cfg: params.cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: resolvedDefault.provider,
});
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) {
return;
}
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
return;
}
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = params.cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
addRawModelRef(raw);
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].toSorted();
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
const isTelegram = params.surface === "telegram";
// Provider list (no provider specified)
if (!provider) {
// For Telegram: show buttons if there are providers
if (isTelegram && providers.length > 0) {
const providerInfos: ProviderInfo[] = providers.map((p) => ({
id: p,
count: byProvider.get(p)?.size ?? 0,
}));
const buttons = buildProviderKeyboard(providerInfos);
const text = "Select a provider:";
return {
text,
channelData: { telegram: { buttons } },
};
}
// Text fallback for non-Telegram surfaces
const lines: string[] = [
"Providers:",
...providers.map((p) =>
@@ -206,6 +248,29 @@ export async function resolveModelsCommandReply(params: {
return { text: lines.join("\n") };
}
// For Telegram: use button-based model list with inline keyboard pagination
if (isTelegram) {
const telegramPageSize = getModelsPageSize();
const totalPages = calculateTotalPages(total, telegramPageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
const buttons = buildModelsKeyboard({
provider,
models,
currentModel: params.currentModel,
currentPage: safePage,
totalPages,
pageSize: telegramPageSize,
});
const text = `Models (${provider}) — ${total} available`;
return {
text,
channelData: { telegram: { buttons } },
};
}
// Text fallback for non-Telegram surfaces
const effectivePageSize = all ? total : pageSize;
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
@@ -251,6 +316,8 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
const reply = await resolveModelsCommandReply({
cfg: params.cfg,
commandBodyNormalized: params.command.commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
});
if (!reply) {
return null;

View File

@@ -153,7 +153,7 @@ describe("/models command", () => {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as unknown as OpenClawConfig;
it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => {
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
@@ -162,8 +162,20 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("Use: /models <provider>");
});
it("lists providers on telegram (buttons)", async () => {
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toBe("Select a provider:");
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
?.telegram?.buttons;
expect(buttons).toBeDefined();
expect(buttons?.length).toBeGreaterThan(0);
});
it("lists provider models with pagination hints", async () => {
const params = buildParams("/models anthropic", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
@@ -174,7 +186,8 @@ describe("/models command", () => {
});
it("ignores page argument when all flag is present", async () => {
const params = buildParams("/models anthropic 3 all", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
@@ -184,7 +197,8 @@ describe("/models command", () => {
});
it("errors on out-of-range pages", async () => {
const params = buildParams("/models anthropic 4", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Page out of range");
@@ -213,11 +227,16 @@ describe("/models command", () => {
},
} as unknown as OpenClawConfig;
const providerList = await handleCommands(buildParams("/models", customCfg));
// Use discord surface for text-based output tests
const providerList = await handleCommands(
buildParams("/models", customCfg, { Surface: "discord" }),
);
expect(providerList.reply?.text).toContain("localai");
expect(providerList.reply?.text).toContain("visionpro");
const result = await handleCommands(buildParams("/models localai", customCfg));
const result = await handleCommands(
buildParams("/models localai", customCfg, { Surface: "discord" }),
);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (localai)");
expect(result.reply?.text).toContain("localai/ultra-chat");

View File

@@ -85,6 +85,7 @@ export async function handleDirectiveOnly(params: {
currentVerboseLevel?: VerboseLevel;
currentReasoningLevel?: ReasoningLevel;
currentElevatedLevel?: ElevatedLevel;
surface?: string;
}): Promise<ReplyPayload | undefined> {
const {
directives,
@@ -132,6 +133,7 @@ export async function handleDirectiveOnly(params: {
aliasIndex,
allowedModelCatalog,
resetModelOverride,
surface: params.surface,
});
if (modelInfo) {
return modelInfo;

View File

@@ -9,6 +9,7 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
import { shortenHomePath } from "../../utils.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import {
@@ -177,6 +178,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
aliasIndex: ModelAliasIndex;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
resetModelOverride: boolean;
surface?: string;
}): Promise<ReplyPayload | undefined> {
if (!params.directives.hasModelDirective) {
return undefined;
@@ -213,6 +215,22 @@ export async function maybeHandleModelDirectiveInfo(params: {
if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
const isTelegram = params.surface === "telegram";
if (isTelegram) {
const buttons = buildBrowseProvidersButton();
return {
text: [
`Current: ${current}`,
"",
"Tap below to browse models, or use:",
"/model <provider/model> to switch",
"/model status for details",
].join("\n"),
channelData: { telegram: { buttons } },
};
}
return {
text: [
`Current: ${current}`,

View File

@@ -183,6 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
surface: ctx.Surface,
});
let statusReply: ReplyPayload | undefined;
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {