From 38b4fb5d55bb043b5aed9fdb9e6bdadb6c4ee100 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:16:26 -0400 Subject: [PATCH] fix(auth/session): preserve override reset behavior and repair oauth profile-id drift (openclaw#18820) thanks @Glucksberg Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + ...ored-profiles-no-config-exists.e2e.test.ts | 68 ++++++++++++++ src/agents/auth-profiles/order.ts | 15 ++- src/agents/model-fallback.e2e.test.ts | 93 ++++++++++++++++++- src/agents/model-fallback.ts | 10 ++ src/auto-reply/reply/commands-models.ts | 67 ++++++++++++- src/auto-reply/reply/get-reply-run.ts | 13 ++- .../reply/session-run-accounting.ts | 14 +-- src/auto-reply/reply/session.ts | 2 + src/commands/auth-choice.apply.oauth.ts | 6 +- src/commands/auth-choice.apply.openai.ts | 4 +- src/commands/auth-choice.e2e.test.ts | 40 +++++++- src/commands/onboard-auth.credentials.ts | 6 +- src/commands/onboard-auth.e2e.test.ts | 31 ++++++- src/sessions/model-overrides.ts | 4 + src/telegram/bot-handlers.ts | 48 +++++++--- 16 files changed, 376 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cea860e..38a740461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. - Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. - Auto-reply/Runner: emit `onAgentRunStart` only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd. +- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg. - Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr. - Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. - CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. diff --git a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts index c5ec9826e..eebbc030c 100644 --- a/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts +++ b/src/agents/auth-profiles.resolve-auth-profile-order.uses-stored-profiles-no-config-exists.e2e.test.ts @@ -49,6 +49,74 @@ describe("resolveAuthProfileOrder", () => { }); expect(order).toEqual(["minimax:prod"]); }); + it("falls back to stored provider profiles when config profile ids drift", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + order: { + "openai-codex": ["openai-codex:default"], + }, + }, + }, + store: { + version: 1, + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + provider: "openai-codex", + }); + expect(order).toEqual(["openai-codex:user@example.com"]); + }); + it("does not bypass explicit ids when the configured profile exists but is invalid", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "token", + }, + }, + order: { + "openai-codex": ["openai-codex:default"], + }, + }, + }, + store: { + version: 1, + profiles: { + "openai-codex:default": { + type: "token", + provider: "openai-codex", + token: "expired-token", + expires: Date.now() - 1_000, + }, + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + provider: "openai-codex", + }); + expect(order).toEqual([]); + }); it("drops explicit order entries that belong to another provider", () => { const order = resolveAuthProfileOrder({ cfg: { diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 405ca081b..045a171fe 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -37,7 +37,7 @@ export function resolveAuthProfileOrder(params: { return []; } - const filtered = baseOrder.filter((profileId) => { + const isValidProfile = (profileId: string): boolean => { const cred = store.profiles[profileId]; if (!cred) { return false; @@ -78,7 +78,18 @@ export function resolveAuthProfileOrder(params: { return Boolean(cred.access?.trim() || cred.refresh?.trim()); } return false; - }); + }; + let filtered = baseOrder.filter(isValidProfile); + + // Repair config/store profile-id drift from older onboarding flows: + // if configured profile ids no longer exist in auth-profiles.json, scan the + // provider's stored credentials and use any valid entries. + const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]); + if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) { + const storeProfiles = listProfilesForProvider(store, providerKey); + filtered = storeProfiles.filter(isValidProfile); + } + const deduped = dedupeProfileIds(filtered); // If user specified explicit order (store override or config), respect it diff --git a/src/agents/model-fallback.e2e.test.ts b/src/agents/model-fallback.e2e.test.ts index ef4555654..318ea1bf6 100644 --- a/src/agents/model-fallback.e2e.test.ts +++ b/src/agents/model-fallback.e2e.test.ts @@ -139,6 +139,75 @@ describe("runWithModelFallback", () => { }); }); + it("falls back directly to configured primary when an override model fails", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5", "openrouter/deepseek-chat"], + }, + }, + }, + }); + + const run = vi.fn().mockImplementation(async (provider, model) => { + if (provider === "anthropic" && model === "claude-opus-4-5") { + throw Object.assign(new Error("unauthorized"), { status: 401 }); + } + if (provider === "openai" && model === "gpt-4.1-mini") { + return "ok"; + } + throw new Error(`unexpected fallback candidate: ${provider}/${model}`); + }); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "claude-opus-4-5", + run, + }); + + expect(result.result).toBe("ok"); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-4.1-mini"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-opus-4-5"], + ["openai", "gpt-4.1-mini"], + ]); + }); + + it("treats normalized default refs as primary and keeps configured fallback chain", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5"], + }, + }, + }, + }); + + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: " OpenAI ", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["openai", "gpt-4.1-mini"], + ["anthropic", "claude-haiku-3-5"], + ]); + }); + it("falls back on transient HTTP 5xx errors", async () => { await expectFallsBackToHaiku({ provider: "openai", @@ -167,12 +236,30 @@ describe("runWithModelFallback", () => { }); }); - it("falls back on credential validation errors", async () => { - await expectFallsBackToHaiku({ + it("falls back to configured primary for override credential validation errors", async () => { + const cfg = makeCfg(); + const run = vi.fn().mockImplementation(async (provider, model) => { + if (provider === "anthropic" && model === "claude-opus-4") { + throw new Error('No credentials found for profile "anthropic:default".'); + } + if (provider === "openai" && model === "gpt-4.1-mini") { + return "ok"; + } + throw new Error(`unexpected fallback candidate: ${provider}/${model}`); + }); + + const result = await runWithModelFallback({ + cfg, provider: "anthropic", model: "claude-opus-4", - firstError: new Error('No credentials found for profile "anthropic:default".'), + run, }); + + expect(result.result).toBe("ok"); + expect(run.mock.calls).toEqual([ + ["anthropic", "claude-opus-4"], + ["openai", "gpt-4.1-mini"], + ]); }); it("skips providers when all profiles are in cooldown", async () => { diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 5356fb3cd..609966c5b 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -96,6 +96,10 @@ type ModelFallbackRunResult = { attempts: FallbackAttempt[]; }; +function sameModelCandidate(a: ModelCandidate, b: ModelCandidate): boolean { + return a.provider === b.provider && a.model === b.model; +} + function throwFallbackFailureSummary(params: { attempts: FallbackAttempt[]; candidates: ModelCandidate[]; @@ -193,6 +197,7 @@ function resolveFallbackCandidates(params: { const providerRaw = String(params.provider ?? "").trim() || defaultProvider; const modelRaw = String(params.model ?? "").trim() || defaultModel; const normalizedPrimary = normalizeModelRef(providerRaw, modelRaw); + const configuredPrimary = normalizeModelRef(defaultProvider, defaultModel); const aliasIndex = buildModelAliasIndex({ cfg: params.cfg ?? {}, defaultProvider, @@ -209,6 +214,11 @@ function resolveFallbackCandidates(params: { if (params.fallbacksOverride !== undefined) { return params.fallbacksOverride; } + // Skip configured fallback chain when the user runs a non-default override. + // In that case, retry should return directly to configured primary. + if (!sameModelCandidate(normalizedPrimary, configuredPrimary)) { + return []; // Override model failed โ†’ go straight to configured default + } const model = params.cfg?.agents?.defaults?.model as | { fallbacks?: string[] } | string diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 9a7afda14..920bf9cb7 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,4 +1,6 @@ +import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; import { buildAllowedModelSet, @@ -8,6 +10,7 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions.js"; import { buildModelsKeyboard, buildProviderKeyboard, @@ -177,11 +180,47 @@ function parseModelsArgs(raw: string): { }; } +function resolveProviderLabel(params: { + provider: string; + cfg: OpenClawConfig; + agentDir?: string; + sessionEntry?: SessionEntry; +}): string { + const authLabel = resolveModelAuthLabel({ + provider: params.provider, + cfg: params.cfg, + sessionEntry: params.sessionEntry, + agentDir: params.agentDir, + }); + if (!authLabel || authLabel === "unknown") { + return params.provider; + } + return `${params.provider} ยท ๐Ÿ”‘ ${authLabel}`; +} + +export function formatModelsAvailableHeader(params: { + provider: string; + total: number; + cfg: OpenClawConfig; + agentDir?: string; + sessionEntry?: SessionEntry; +}): string { + const providerLabel = resolveProviderLabel({ + provider: params.provider, + cfg: params.cfg, + agentDir: params.agentDir, + sessionEntry: params.sessionEntry, + }); + return `Models (${providerLabel}) โ€” ${params.total} available`; +} + export async function resolveModelsCommandReply(params: { cfg: OpenClawConfig; commandBodyNormalized: string; surface?: string; currentModel?: string; + agentDir?: string; + sessionEntry?: SessionEntry; }): Promise { const body = params.commandBodyNormalized.trim(); if (!body.startsWith("/models")) { @@ -237,10 +276,16 @@ export async function resolveModelsCommandReply(params: { const models = [...(byProvider.get(provider) ?? new Set())].toSorted(); const total = models.length; + const providerLabel = resolveProviderLabel({ + provider, + cfg: params.cfg, + agentDir: params.agentDir, + sessionEntry: params.sessionEntry, + }); if (total === 0) { const lines: string[] = [ - `Models (${provider}) โ€” none`, + `Models (${providerLabel}) โ€” none`, "", "Browse: /models", "Switch: /model ", @@ -263,7 +308,13 @@ export async function resolveModelsCommandReply(params: { pageSize: telegramPageSize, }); - const text = `Models (${provider}) โ€” ${total} available`; + const text = formatModelsAvailableHeader({ + provider, + total, + cfg: params.cfg, + agentDir: params.agentDir, + sessionEntry: params.sessionEntry, + }); return { text, channelData: { telegram: { buttons } }, @@ -289,7 +340,7 @@ export async function resolveModelsCommandReply(params: { const endIndexExclusive = Math.min(total, startIndex + effectivePageSize); const pageModels = models.slice(startIndex, endIndexExclusive); - const header = `Models (${provider}) โ€” showing ${startIndex + 1}-${endIndexExclusive} of ${total} (page ${safePage}/${pageCount})`; + const header = `Models (${providerLabel}) โ€” showing ${startIndex + 1}-${endIndexExclusive} of ${total} (page ${safePage}/${pageCount})`; const lines: string[] = [header]; for (const id of pageModels) { @@ -313,11 +364,21 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma return null; } + const modelsAgentId = + params.agentId ?? + resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const modelsAgentDir = resolveAgentDir(params.cfg, modelsAgentId); + const reply = await resolveModelsCommandReply({ cfg: params.cfg, commandBodyNormalized: params.command.commandBodyNormalized, surface: params.ctx.Surface, currentModel: params.model ? `${params.provider}/${params.model}` : undefined, + agentDir: modelsAgentDir, + sessionEntry: params.sessionEntry, }); if (!reply) { return null; diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index e5d894099..bcbaf72f5 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; +import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, @@ -325,10 +326,18 @@ export async function runPreparedReply( if (channel && to) { const modelLabel = `${provider}/${model}`; const defaultLabel = `${defaultProvider}/${defaultModel}`; + const modelAuthLabel = resolveModelAuthLabel({ + provider, + cfg, + sessionEntry, + agentDir, + }); + const authSuffix = + modelAuthLabel && modelAuthLabel !== "unknown" ? ` ยท ๐Ÿ”‘ ${modelAuthLabel}` : ""; const text = modelLabel === defaultLabel - ? `โœ… New session started ยท model: ${modelLabel}` - : `โœ… New session started ยท model: ${modelLabel} (default: ${defaultLabel})`; + ? `โœ… New session started ยท model: ${modelLabel}${authSuffix}` + : `โœ… New session started ยท model: ${modelLabel} (default: ${defaultLabel})${authSuffix}`; await routeReply({ payload: { text }, channel, diff --git a/src/auto-reply/reply/session-run-accounting.ts b/src/auto-reply/reply/session-run-accounting.ts index d1d17ad93..fe4b91a7c 100644 --- a/src/auto-reply/reply/session-run-accounting.ts +++ b/src/auto-reply/reply/session-run-accounting.ts @@ -13,19 +13,7 @@ type IncrementRunCompactionCountParams = Omit< }; export async function persistRunSessionUsage(params: PersistRunSessionUsageParams): Promise { - await persistSessionUsageUpdate({ - storePath: params.storePath, - sessionKey: params.sessionKey, - usage: params.usage, - lastCallUsage: params.lastCallUsage, - promptTokens: params.promptTokens, - modelUsed: params.modelUsed, - providerUsed: params.providerUsed, - contextTokensUsed: params.contextTokensUsed, - systemPromptReport: params.systemPromptReport, - cliSessionId: params.cliSessionId, - logLabel: params.logLabel, - }); + await persistSessionUsageUpdate(params); } export async function incrementRunCompactionCount( diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index cd0ccf75b..4167e1727 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -256,6 +256,8 @@ export async function initSessionState(params: { persistedVerbose = entry.verboseLevel; persistedReasoning = entry.reasoningLevel; persistedTtsAuto = entry.ttsAuto; + persistedModelOverride = entry.modelOverride; + persistedProviderOverride = entry.providerOverride; } } diff --git a/src/commands/auth-choice.apply.oauth.ts b/src/commands/auth-choice.apply.oauth.ts index ca5a85587..0e9a5523c 100644 --- a/src/commands/auth-choice.apply.oauth.ts +++ b/src/commands/auth-choice.apply.oauth.ts @@ -68,11 +68,7 @@ export async function applyAuthChoiceOAuth( }); spin.stop("Chutes OAuth complete"); - const email = - typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; - const profileId = `chutes:${email}`; - - await writeOAuthCredentials("chutes", creds, params.agentDir); + const profileId = await writeOAuthCredentials("chutes", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId, provider: "chutes", diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index a7e0fa976..7555245f1 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -117,9 +117,9 @@ export async function applyAuthChoiceOpenAI( return { config: nextConfig, agentModelOverride }; } if (creds) { - await writeOAuthCredentials("openai-codex", creds, params.agentDir); + const profileId = await writeOAuthCredentials("openai-codex", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: "openai-codex:default", + profileId, provider: "openai-codex", mode: "oauth", }); diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index a886f01d2..e6afea37e 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; @@ -22,7 +23,9 @@ vi.mock("../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: vi.fn(async () => {}), })); -const loginOpenAICodexOAuth = vi.hoisted(() => vi.fn(async () => null)); +const loginOpenAICodexOAuth = vi.hoisted(() => + vi.fn<() => Promise>(async () => null), +); vi.mock("./openai-codex-oauth.js", () => ({ loginOpenAICodexOAuth, })); @@ -123,6 +126,41 @@ describe("applyAuthChoice", () => { ).resolves.toEqual({ config: {} }); }); + it("stores openai-codex OAuth with email profile id", async () => { + await setupTempState(); + + loginOpenAICodexOAuth.mockResolvedValueOnce({ + email: "user@example.com", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + }); + + const prompter = createPrompter({}); + const runtime = createExitThrowingRuntime(); + + const result = await applyAuthChoice({ + authChoice: "openai-codex", + config: {}, + prompter, + runtime, + setDefaultModel: false, + }); + + expect(result.config.auth?.profiles?.["openai-codex:user@example.com"]).toMatchObject({ + provider: "openai-codex", + mode: "oauth", + }); + expect(result.config.auth?.profiles?.["openai-codex:default"]).toBeUndefined(); + expect(await readAuthProfile("openai-codex:user@example.com")).toMatchObject({ + type: "oauth", + provider: "openai-codex", + refresh: "refresh-token", + access: "access-token", + email: "user@example.com", + }); + }); + it("prompts and writes MiniMax API key when selecting minimax-api", async () => { await setupTempState(); diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index c99b28a5b..69f5e306f 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -10,11 +10,12 @@ export async function writeOAuthCredentials( provider: string, creds: OAuthCredentials, agentDir?: string, -): Promise { +): Promise { const email = typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; + const profileId = `${provider}:${email}`; upsertAuthProfile({ - profileId: `${provider}:${email}`, + profileId, credential: { type: "oauth", provider, @@ -22,6 +23,7 @@ export async function writeOAuthCredentials( }, agentDir: resolveAuthAgentDir(agentDir), }); + return profileId; } export async function setAnthropicApiKey(key: string, agentDir?: string) { diff --git a/src/commands/onboard-auth.e2e.test.ts b/src/commands/onboard-auth.e2e.test.ts index 753630fc5..2389aee79 100644 --- a/src/commands/onboard-auth.e2e.test.ts +++ b/src/commands/onboard-auth.e2e.test.ts @@ -125,12 +125,13 @@ describe("writeOAuthCredentials", () => { expires: Date.now() + 60_000, } satisfies OAuthCredentials; - await writeOAuthCredentials("openai-codex", creds); + const profileId = await writeOAuthCredentials("openai-codex", creds); + expect(profileId).toBe("openai-codex:default"); const parsed = await readAuthProfilesForAgent<{ profiles?: Record; }>(env.agentDir); - expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({ + expect(parsed.profiles?.[profileId]).toMatchObject({ refresh: "refresh-token", access: "access-token", type: "oauth", @@ -140,6 +141,32 @@ describe("writeOAuthCredentials", () => { fs.readFile(path.join(env.stateDir, "agents", "main", "agent", "auth-profiles.json"), "utf8"), ).rejects.toThrow(); }); + + it("uses OAuth email as profile id when provided", async () => { + const env = await setupAuthTestEnv("openclaw-oauth-"); + lifecycle.setStateDir(env.stateDir); + + const creds = { + email: "user@example.com", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 60_000, + } satisfies OAuthCredentials; + + const profileId = await writeOAuthCredentials("openai-codex", creds); + expect(profileId).toBe("openai-codex:user@example.com"); + + const parsed = await readAuthProfilesForAgent<{ + profiles?: Record; + }>(env.agentDir); + expect(parsed.profiles?.[profileId]).toMatchObject({ + refresh: "refresh-token", + access: "access-token", + type: "oauth", + provider: "openai-codex", + email: "user@example.com", + }); + }); }); describe("setMinimaxApiKey", () => { diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 97795d98d..5a8b9ea82 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -64,7 +64,11 @@ export function applyModelOverrideToSessionEntry(params: { } } + // Clear stale fallback notice when the user explicitly switches models. if (updated) { + delete entry.fallbackNoticeSelectedModel; + delete entry.fallbackNoticeActiveModel; + delete entry.fallbackNoticeReason; entry.updatedAt = Date.now(); } diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 3d62a1480..1e51e0dbc 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -1,12 +1,15 @@ import type { Message, ReactionTypeEmoji } from "@grammyjs/types"; -import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, } from "../auto-reply/inbound-debounce.js"; import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js"; -import { buildModelsProviderData } from "../auto-reply/reply/commands-models.js"; +import { + buildModelsProviderData, + formatModelsAvailableHeader, +} from "../auto-reply/reply/commands-models.js"; import { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js"; import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { buildCommandsMessagePaginated } from "../auto-reply/status.js"; @@ -182,13 +185,17 @@ export const registerTelegramHandlers = ({ }, }); - const resolveTelegramSessionModel = (params: { + const resolveTelegramSessionState = (params: { chatId: number | string; isGroup: boolean; isForum: boolean; messageThreadId?: number; resolvedThreadId?: number; - }): string | undefined => { + }): { + agentId: string; + sessionEntry: ReturnType[string]; + model?: string; + } => { const resolvedThreadId = params.resolvedThreadId ?? resolveTelegramForumThreadId({ @@ -229,17 +236,29 @@ export const registerTelegramHandlers = ({ sessionKey, }); if (storedOverride) { - return storedOverride.provider - ? `${storedOverride.provider}/${storedOverride.model}` - : storedOverride.model; + return { + agentId: route.agentId, + sessionEntry: entry, + model: storedOverride.provider + ? `${storedOverride.provider}/${storedOverride.model}` + : storedOverride.model, + }; } const provider = entry?.modelProvider?.trim(); const model = entry?.model?.trim(); if (provider && model) { - return `${provider}/${model}`; + return { + agentId: route.agentId, + sessionEntry: entry, + model: `${provider}/${model}`, + }; } const modelCfg = cfg.agents?.defaults?.model; - return typeof modelCfg === "string" ? modelCfg : modelCfg?.primary; + return { + agentId: route.agentId, + sessionEntry: entry, + model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary, + }; }; const processMediaGroup = async (entry: MediaGroupEntry) => { @@ -933,13 +952,14 @@ export const registerTelegramHandlers = ({ const safePage = Math.max(1, Math.min(page, totalPages)); // Resolve current model from session (prefer overrides) - const currentModel = resolveTelegramSessionModel({ + const sessionState = resolveTelegramSessionState({ chatId, isGroup, isForum, messageThreadId, resolvedThreadId, }); + const currentModel = sessionState.model; const buttons = buildModelsKeyboard({ provider, @@ -949,7 +969,13 @@ export const registerTelegramHandlers = ({ totalPages, pageSize, }); - const text = `Models (${provider}) โ€” ${models.length} available`; + const text = formatModelsAvailableHeader({ + provider, + total: models.length, + cfg, + agentDir: resolveAgentDir(cfg, sessionState.agentId), + sessionEntry: sessionState.sessionEntry, + }); await editMessageWithButtons(text, buttons); return; }