fix(telegram): align model picker overrides and fallback allowlists

This commit is contained in:
Radek Sienkiewicz 2026-03-12 12:45:31 +01:00
parent e62e95b87b
commit 6c687eebf5
No known key found for this signature in database
GPG Key ID: F76BB7FE39C6D0C7
12 changed files with 207 additions and 69 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
@ -204,6 +205,7 @@ Docs: https://docs.openclaw.ai
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
<<<<<<< HEAD
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Docs/browser: add a layered WSL2 + Windows remote Chrome CDP troubleshooting guide, including Control UI origin pitfalls and extension-relay bind-address guidance. (#39407) Thanks @Owlock.
@ -233,7 +235,9 @@ Docs: https://docs.openclaw.ai
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
- 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.
- # 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.
> > > > > > > 5b3994705 (fix(telegram): align model picker overrides and fallback allowlists)
## 2026.3.7

View File

@ -376,6 +376,44 @@ describe("model-selection", () => {
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,21 +488,21 @@ export function buildAllowedModelSet(params: {
}
}
const fallbackConfig = params.cfg.agents?.defaults?.model;
if (fallbackConfig && typeof fallbackConfig === "object") {
for (const fallback of fallbackConfig.fallbacks ?? []) {
const parsed = parseModelRef(String(fallback), params.defaultProvider);
if (parsed) {
const key = modelKey(parsed.provider, parsed.model);
allowedKeys.add(key);
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 (!catalogKeys.has(key) && !syntheticCatalogEntries.has(key)) {
syntheticCatalogEntries.set(key, {
id: parsed.model,
name: parsed.model,
provider: parsed.provider,
});
}
}
}

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,
@ -1397,26 +1398,13 @@ export const registerTelegramHandlers = ({
agentId: sessionState.agentId,
});
const agentConfig = cfg.agents?.list?.find((a) => a.id === sessionState.agentId);
const agentModelConfig = agentConfig?.model ?? cfg.agents?.defaults?.model;
const rawModelRef =
typeof agentModelConfig === "string" ? agentModelConfig : agentModelConfig?.primary;
let resolvedDefaultRef = sessionState.model;
if (rawModelRef) {
const trimmed = rawModelRef.trim();
if (trimmed.includes("/")) {
resolvedDefaultRef = trimmed;
} else {
const currentProvider = sessionState.model?.split("/")[0];
resolvedDefaultRef = currentProvider
? `${currentProvider}/${trimmed}`
: sessionState.model;
}
}
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
agentId: sessionState.agentId,
});
const isDefaultSelection =
`${selection.provider}/${selection.model}` === resolvedDefaultRef;
selection.provider === resolvedDefault.provider &&
selection.model === resolvedDefault.model;
await updateSessionStore(storePath, (store) => {
const sessionKey = sessionState.sessionKey;

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