fix(telegram): allow fallback models in /model validation (#40105)

Merged via squash.

Prepared head SHA: de07585e03
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
This commit is contained in:
avirweb
2026-03-12 07:55:51 -05:00
committed by GitHub
parent 171d2df9e0
commit f2e28fc30f
12 changed files with 313 additions and 45 deletions

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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);
}

View File

@@ -151,6 +151,7 @@ async function resolveModelOverride(params: {
catalog,
defaultProvider: currentProvider,
defaultModel: currentModel,
agentId: params.agentId,
});
const resolved = resolveModelRefFromString({

View File

@@ -49,6 +49,7 @@ export async function buildModelsProviderData(
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId,
});
const aliasIndex = buildModelAliasIndex({

View File

@@ -373,6 +373,7 @@ export async function resolveReplyDirectives(params: {
const modelState = await createModelSelectionState({
cfg,
agentId,
agentCfg,
sessionEntry,
sessionStore,

View File

@@ -175,6 +175,7 @@ export async function getReplyFromConfig(
await applyResetModelOverride({
cfg,
agentId,
resetTriggered,
bodyStripped,
sessionCtx,

View File

@@ -263,6 +263,7 @@ function scoreFuzzyMatch(params: {
export async function createModelSelectionState(params: {
cfg: OpenClawConfig;
agentId?: string;
agentCfg: NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
@@ -315,6 +316,7 @@ export async function createModelSelectionState(params: {
catalog: modelCatalog,
defaultProvider,
defaultModel,
agentId: params.agentId,
});
allowedModelCatalog = allowed.allowedCatalog;
allowedModelKeys = allowed.allowedKeys;

View File

@@ -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) {

View File

@@ -950,6 +950,7 @@ async function agentCommandInternal(
catalog: modelCatalog,
defaultProvider,
defaultModel,
agentId: sessionAgentId,
});
allowedModelKeys = allowed.allowedKeys;
allowedModelCatalog = allowed.allowedCatalog;

View File

@@ -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<typeof loadSessionStore>[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;
}

View File

@@ -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<string, unknown>) => Promise<void>;
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<string, unknown>,
) => Promise<void>;
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<string, unknown>) => Promise<void>;
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 () => {