mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 23:11:37 +00:00
Telegram: add inline button model selection for /models and /model commands
This commit is contained in:
committed by
Ayaan Zaidi
parent
efb4a34be4
commit
16349b6e93
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user