From f2e28fc30fe88cb4816a883f3a4c2e6d06cbeaf9 Mon Sep 17 00:00:00 2001 From: avirweb Date: Thu, 12 Mar 2026 07:55:51 -0500 Subject: [PATCH] fix(telegram): allow fallback models in /model validation (#40105) Merged via squash. Prepared head SHA: de07585e03cba06897d50c1d79fbe09d326c6ac9 Co-authored-by: avirweb <257412074+avirweb@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 2 + src/agents/model-selection.test.ts | 92 ++++++++++++ src/agents/model-selection.ts | 42 +++++- src/agents/tools/session-status-tool.ts | 1 + src/auto-reply/reply/commands-models.ts | 1 + src/auto-reply/reply/get-reply-directives.ts | 1 + src/auto-reply/reply/get-reply.ts | 1 + src/auto-reply/reply/model-selection.ts | 2 + src/auto-reply/reply/session-reset-model.ts | 2 + src/commands/agent.ts | 1 + src/telegram/bot-handlers.ts | 67 +++++++-- src/telegram/bot.test.ts | 146 ++++++++++++++----- 12 files changed, 313 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f249a91e7..9820a7aa1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Gateway/main-session routing: keep TUI and other `mode:UI` main-session sends on the internal surface when `deliver` is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus. - Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. - Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. +- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. ## 2026.3.11 @@ -234,6 +235,7 @@ Docs: https://docs.openclaw.ai - CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc. - Memory/Gemini: normalize returned Gemini embeddings across direct query, direct batch, and async batch paths so memory search uses consistent vector handling for Gemini too. (#43409) Thanks @gumadeiras. - Agents/failover: recognize additional serialized network errno strings plus `EHOSTDOWN` and `EPIPE` structured codes so transient transport failures trigger timeout failover more reliably. (#42830) Thanks @jnMetaCode. +- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb. ## 2026.3.7 diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index a9029540ee1..938f5558920 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -322,6 +322,98 @@ describe("model-selection", () => { { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, ]); }); + + it("includes fallback models in allowed set", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-4o": {}, + }, + model: { + primary: "openai/gpt-4o", + fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"], + }, + }, + }, + } as OpenClawConfig; + + const result = buildAllowedModelSet({ + cfg, + catalog: [], + defaultProvider: "openai", + defaultModel: "gpt-4o", + }); + + expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); + expect(result.allowedKeys.has("google/gemini-3-pro-preview")).toBe(true); + expect(result.allowAny).toBe(false); + }); + + it("handles empty fallbacks gracefully", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-4o": {}, + }, + model: { + primary: "openai/gpt-4o", + fallbacks: [], + }, + }, + }, + } as OpenClawConfig; + + const result = buildAllowedModelSet({ + cfg, + catalog: [], + defaultProvider: "openai", + defaultModel: "gpt-4o", + }); + + expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true); + expect(result.allowAny).toBe(false); + }); + + it("prefers per-agent fallback overrides when agentId is provided", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-4o": {}, + }, + model: { + primary: "openai/gpt-4o", + fallbacks: ["google/gemini-3-pro"], + }, + }, + list: [ + { + id: "coder", + model: { + primary: "openai/gpt-4o", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + ], + }, + } as OpenClawConfig; + + const result = buildAllowedModelSet({ + cfg, + catalog: [], + defaultProvider: "openai", + defaultModel: "gpt-4o", + agentId: "coder", + }); + + expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true); + expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); + expect(result.allowedKeys.has("google/gemini-3-pro-preview")).toBe(false); + expect(result.allowAny).toBe(false); + }); }); describe("resolveAllowedModelRef", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 205c2f1cce0..d43322cbe9f 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,8 +1,16 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveAgentModelPrimaryValue, toAgentModelListLike } from "../config/model-input.js"; +import { + resolveAgentModelFallbackValues, + resolveAgentModelPrimaryValue, + toAgentModelListLike, +} from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { sanitizeForLog } from "../terminal/ansi.js"; -import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "./agent-scope.js"; +import { + resolveAgentConfig, + resolveAgentEffectiveModelPrimary, + resolveAgentModelFallbacksOverride, +} from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; @@ -382,6 +390,16 @@ export function resolveDefaultModelForAgent(params: { }); } +function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string }): string[] { + if (params.agentId) { + const override = resolveAgentModelFallbacksOverride(params.cfg, params.agentId); + if (override !== undefined) { + return override; + } + } + return resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model); +} + export function resolveSubagentConfiguredModelSelection(params: { cfg: OpenClawConfig; agentId: string; @@ -419,6 +437,7 @@ export function buildAllowedModelSet(params: { catalog: ModelCatalogEntry[]; defaultProvider: string; defaultModel?: string; + agentId?: string; }): { allowAny: boolean; allowedCatalog: ModelCatalogEntry[]; @@ -469,6 +488,25 @@ export function buildAllowedModelSet(params: { } } + for (const fallback of resolveAllowedFallbacks({ + cfg: params.cfg, + agentId: params.agentId, + })) { + const parsed = parseModelRef(String(fallback), params.defaultProvider); + if (parsed) { + const key = modelKey(parsed.provider, parsed.model); + allowedKeys.add(key); + + if (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) { + syntheticCatalogEntries.set(key, { + id: parsed.model, + name: parsed.model, + provider: parsed.provider, + }); + } + } + } + if (defaultKey) { allowedKeys.add(defaultKey); } diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 29d8204b750..aa9e0cac17b 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -151,6 +151,7 @@ async function resolveModelOverride(params: { catalog, defaultProvider: currentProvider, defaultModel: currentModel, + agentId: params.agentId, }); const resolved = resolveModelRefFromString({ diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index c23e6d851b2..17c25a6bfe0 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -49,6 +49,7 @@ export async function buildModelsProviderData( catalog, defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, + agentId, }); const aliasIndex = buildModelAliasIndex({ diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 4c9da28deae..a14798d8048 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -373,6 +373,7 @@ export async function resolveReplyDirectives(params: { const modelState = await createModelSelectionState({ cfg, + agentId, agentCfg, sessionEntry, sessionStore, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index be4c8d362f8..81dd478a84a 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -175,6 +175,7 @@ export async function getReplyFromConfig( await applyResetModelOverride({ cfg, + agentId, resetTriggered, bodyStripped, sessionCtx, diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 1b666b6ded5..95c01460c3d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -263,6 +263,7 @@ function scoreFuzzyMatch(params: { export async function createModelSelectionState(params: { cfg: OpenClawConfig; + agentId?: string; agentCfg: NonNullable["defaults"]> | undefined; sessionEntry?: SessionEntry; sessionStore?: Record; @@ -315,6 +316,7 @@ export async function createModelSelectionState(params: { catalog: modelCatalog, defaultProvider, defaultModel, + agentId: params.agentId, }); allowedModelCatalog = allowed.allowedCatalog; allowedModelKeys = allowed.allowedKeys; diff --git a/src/auto-reply/reply/session-reset-model.ts b/src/auto-reply/reply/session-reset-model.ts index efc2a2536b4..101720e2dd2 100644 --- a/src/auto-reply/reply/session-reset-model.ts +++ b/src/auto-reply/reply/session-reset-model.ts @@ -87,6 +87,7 @@ function applySelectionToSession(params: { export async function applyResetModelOverride(params: { cfg: OpenClawConfig; + agentId?: string; resetTriggered: boolean; bodyStripped?: string; sessionCtx: TemplateContext; @@ -118,6 +119,7 @@ export async function applyResetModelOverride(params: { catalog, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, + agentId: params.agentId, }); const allowedModelKeys = allowed.allowedKeys; if (allowedModelKeys.size === 0) { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 74a5078d03b..ab690b37666 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -950,6 +950,7 @@ async function agentCommandInternal( catalog: modelCatalog, defaultProvider, defaultModel, + agentId: sessionAgentId, }); allowedModelKeys = allowed.allowedKeys; allowedModelCatalog = allowed.allowedCatalog; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 2d1327bcd5f..0fd7bbd241b 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -1,5 +1,6 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -20,6 +21,7 @@ import { loadSessionStore, resolveSessionStoreEntry, resolveStorePath, + updateSessionStore, } from "../config/sessions.js"; import type { DmPolicy } from "../config/types.base.js"; import type { @@ -33,6 +35,7 @@ import { MediaFetchError } from "../media/fetch.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; +import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { isSenderAllowed, @@ -300,6 +303,7 @@ export const registerTelegramHandlers = ({ }): { agentId: string; sessionEntry: ReturnType[string] | undefined; + sessionKey: string; model?: string; } => { const resolvedThreadId = @@ -339,6 +343,7 @@ export const registerTelegramHandlers = ({ return { agentId: route.agentId, sessionEntry: entry, + sessionKey, model: storedOverride.provider ? `${storedOverride.provider}/${storedOverride.model}` : storedOverride.model, @@ -350,6 +355,7 @@ export const registerTelegramHandlers = ({ return { agentId: route.agentId, sessionEntry: entry, + sessionKey, model: `${provider}/${model}`, }; } @@ -357,6 +363,7 @@ export const registerTelegramHandlers = ({ return { agentId: route.agentId, sessionEntry: entry, + sessionKey, model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary, }; }; @@ -1374,16 +1381,56 @@ export const registerTelegramHandlers = ({ ); return; } - // Process model selection as a synthetic message with /model command - const syntheticMessage = buildSyntheticTextMessage({ - base: callbackMessage, - from: callback.from, - text: `/model ${selection.provider}/${selection.model}`, - }); - await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, { - forceWasMentioned: true, - messageIdOverride: callback.id, - }); + + const modelSet = byProvider.get(selection.provider); + if (!modelSet?.has(selection.model)) { + await editMessageWithButtons( + `❌ Model "${selection.provider}/${selection.model}" is not allowed.`, + [], + ); + return; + } + + // Directly set model override in session + try { + // Get session store path + const storePath = resolveStorePath(cfg.session?.store, { + agentId: sessionState.agentId, + }); + + const resolvedDefault = resolveDefaultModelForAgent({ + cfg, + agentId: sessionState.agentId, + }); + const isDefaultSelection = + selection.provider === resolvedDefault.provider && + selection.model === resolvedDefault.model; + + await updateSessionStore(storePath, (store) => { + const sessionKey = sessionState.sessionKey; + const entry = store[sessionKey] ?? {}; + store[sessionKey] = entry; + applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: selection.provider, + model: selection.model, + isDefault: isDefaultSelection, + }, + }); + }); + + // Update message to show success with visual feedback + const actionText = isDefaultSelection + ? "reset to default" + : `changed to **${selection.provider}/${selection.model}**`; + await editMessageWithButtons( + `✅ Model ${actionText}\n\nThis model will be used for your next message.`, + [], // Empty buttons = remove inline keyboard + ); + } catch (err) { + await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []); + } return; } diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 043d529b408..d8c8bc14ade 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1,3 +1,4 @@ +import { rm } from "node:fs/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; @@ -5,6 +6,7 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { loadSessionStore } from "../config/sessions.js"; import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js"; import { answerCallbackQuerySpy, @@ -531,49 +533,127 @@ describe("createTelegramBot", () => { it("routes compact model callbacks by inferring provider", async () => { onSpy.mockClear(); replySpy.mockClear(); + editMessageTextSpy.mockClear(); const modelId = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"; + const storePath = `/tmp/openclaw-telegram-model-compact-${process.pid}-${Date.now()}.json`; - createTelegramBot({ - token: "tok", - config: { - agents: { - defaults: { - model: `bedrock/${modelId}`, + await rm(storePath, { force: true }); + try { + createTelegramBot({ + token: "tok", + config: { + agents: { + defaults: { + model: `bedrock/${modelId}`, + }, + }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + session: { + store: storePath, }, }, - channels: { - telegram: { - dmPolicy: "open", - allowFrom: ["*"], + }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); + + await callbackHandler({ + callbackQuery: { + id: "cbq-model-compact-1", + data: `mdl_sel/${modelId}`, + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 14, }, }, - }, - }); - const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( - ctx: Record, - ) => Promise; - expect(callbackHandler).toBeDefined(); + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); - await callbackHandler({ - callbackQuery: { - id: "cbq-model-compact-1", - data: `mdl_sel/${modelId}`, - from: { id: 9, first_name: "Ada", username: "ada_bot" }, - message: { - chat: { id: 1234, type: "private" }, - date: 1736380800, - message_id: 14, + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain("✅ Model reset to default"); + + const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; + expect(entry?.providerOverride).toBeUndefined(); + expect(entry?.modelOverride).toBeUndefined(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-1"); + } finally { + await rm(storePath, { force: true }); + } + }); + + it("resets overrides when selecting the configured default model", async () => { + onSpy.mockClear(); + replySpy.mockClear(); + editMessageTextSpy.mockClear(); + + const storePath = `/tmp/openclaw-telegram-model-default-${process.pid}-${Date.now()}.json`; + + await rm(storePath, { force: true }); + try { + createTelegramBot({ + token: "tok", + config: { + agents: { + defaults: { + model: "claude-opus-4-6", + models: { + "anthropic/claude-opus-4-6": {}, + }, + }, + }, + channels: { + telegram: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + session: { + store: storePath, + }, }, - }, - me: { username: "openclaw_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + }); + const callbackHandler = onSpy.mock.calls.find( + (call) => call[0] === "callback_query", + )?.[1] as (ctx: Record) => Promise; + expect(callbackHandler).toBeDefined(); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0]?.[0]; - expect(payload?.Body).toContain(`/model amazon-bedrock/${modelId}`); - expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-compact-1"); + await callbackHandler({ + callbackQuery: { + id: "cbq-model-default-1", + data: "mdl_sel_anthropic/claude-opus-4-6", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 16, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + expect(editMessageTextSpy).toHaveBeenCalledTimes(1); + expect(editMessageTextSpy.mock.calls[0]?.[2]).toContain("✅ Model reset to default"); + + const entry = Object.values(loadSessionStore(storePath, { skipCache: true }))[0]; + expect(entry?.providerOverride).toBeUndefined(); + expect(entry?.modelOverride).toBeUndefined(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-default-1"); + } finally { + await rm(storePath, { force: true }); + } }); it("rejects ambiguous compact model callbacks and returns provider list", async () => {