mirror of https://github.com/openclaw/openclaw.git
fix(telegram): align model picker overrides and fallback allowlists
This commit is contained in:
parent
e62e95b87b
commit
6c687eebf5
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ async function resolveModelOverride(params: {
|
|||
catalog,
|
||||
defaultProvider: currentProvider,
|
||||
defaultModel: currentModel,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
|
||||
const resolved = resolveModelRefFromString({
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export async function buildModelsProviderData(
|
|||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
agentId,
|
||||
});
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ export async function resolveReplyDirectives(params: {
|
|||
|
||||
const modelState = await createModelSelectionState({
|
||||
cfg,
|
||||
agentId,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export async function getReplyFromConfig(
|
|||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
agentId,
|
||||
resetTriggered,
|
||||
bodyStripped,
|
||||
sessionCtx,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -950,6 +950,7 @@ async function agentCommandInternal(
|
|||
catalog: modelCatalog,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
allowedModelKeys = allowed.allowedKeys;
|
||||
allowedModelCatalog = allowed.allowedCatalog;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue