diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index 14480e7858..fe6873bbe3 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -15,30 +15,30 @@ const baseAttempt = { describe("fallback-state", () => { it("treats fallback as active only when state matches selected and active refs", () => { const state: FallbackNoticeState = { - fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5", + fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", }; const resolved = resolveActiveFallbackState({ - selectedModelRef: "fireworks/fireworks/minimax-m2p5", + selectedModelRef: "fireworks/minimax-m2p5", activeModelRef: "deepinfra/moonshotai/Kimi-K2.5", state, }); expect(resolved.active).toBe(true); - expect(resolved.reason).toBe("fireworks/fireworks/minimax-m2p5 rate limit"); + expect(resolved.reason).toBe("rate limit"); }); it("does not treat runtime drift as fallback when persisted state does not match", () => { const state: FallbackNoticeState = { fallbackNoticeSelectedModel: "anthropic/claude", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "anthropic/claude rate limit", + fallbackNoticeReason: "rate limit", }; const resolved = resolveActiveFallbackState({ - selectedModelRef: "fireworks/fireworks/minimax-m2p5", + selectedModelRef: "fireworks/minimax-m2p5", activeModelRef: "deepinfra/moonshotai/Kimi-K2.5", state, }); @@ -61,8 +61,8 @@ describe("fallback-state", () => { expect(resolved.fallbackTransitioned).toBe(true); expect(resolved.fallbackCleared).toBe(false); expect(resolved.stateChanged).toBe(true); - expect(resolved.reasonSummary).toBe("fireworks/fireworks/minimax-m2p5 rate limit"); - expect(resolved.nextState.selectedModel).toBe("fireworks/fireworks/minimax-m2p5"); + expect(resolved.reasonSummary).toBe("rate limit"); + expect(resolved.nextState.selectedModel).toBe("fireworks/minimax-m2p5"); expect(resolved.nextState.activeModel).toBe("deepinfra/moonshotai/Kimi-K2.5"); }); @@ -74,15 +74,15 @@ describe("fallback-state", () => { activeModel: "moonshotai/Kimi-K2.5", attempts: [{ ...baseAttempt, reason: "timeout" }], state: { - fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5", + fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", }, }); expect(resolved.fallbackTransitioned).toBe(false); expect(resolved.stateChanged).toBe(true); - expect(resolved.nextState.reason).toBe("fireworks/fireworks/minimax-m2p5 timeout"); + expect(resolved.nextState.reason).toBe("timeout"); }); it("marks fallback as cleared when runtime returns to selected model", () => { @@ -93,9 +93,9 @@ describe("fallback-state", () => { activeModel: "fireworks/minimax-m2p5", attempts: [], state: { - fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5", + fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", }, }); diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index a078d6e0eb..d1c7c8d91a 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -1,4 +1,5 @@ import type { SessionEntry } from "../config/sessions.js"; +import { formatProviderModelRef } from "./model-runtime.js"; import type { RuntimeFallbackAttempt } from "./reply/agent-runner-execution.js"; const FALLBACK_REASON_PART_MAX = 80; @@ -37,13 +38,13 @@ export function formatFallbackAttemptReason(attempt: RuntimeFallbackAttempt): st } function formatFallbackAttemptSummary(attempt: RuntimeFallbackAttempt): string { - return `${attempt.provider}/${attempt.model} ${formatFallbackAttemptReason(attempt)}`; + return `${formatProviderModelRef(attempt.provider, attempt.model)} ${formatFallbackAttemptReason(attempt)}`; } export function buildFallbackReasonSummary(attempts: RuntimeFallbackAttempt[]): string { const firstAttempt = attempts[0]; const firstReason = firstAttempt - ? formatFallbackAttemptSummary(firstAttempt) + ? formatFallbackAttemptReason(firstAttempt) : "selected model unavailable"; const moreAttempts = attempts.length > 1 ? ` (+${attempts.length - 1} more attempts)` : ""; return `${truncateFallbackReasonPart(firstReason)}${moreAttempts}`; @@ -62,8 +63,8 @@ export function buildFallbackNotice(params: { activeModel: string; attempts: RuntimeFallbackAttempt[]; }): string | null { - const selected = `${params.selectedProvider}/${params.selectedModel}`; - const active = `${params.activeProvider}/${params.activeModel}`; + const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel); + const active = formatProviderModelRef(params.activeProvider, params.activeModel); if (selected === active) { return null; } @@ -76,7 +77,7 @@ export function buildFallbackClearedNotice(params: { selectedModel: string; previousActiveModel?: string; }): string { - const selected = `${params.selectedProvider}/${params.selectedModel}`; + const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel); const previous = normalizeFallbackModelRef(params.previousActiveModel); if (previous && previous !== selected) { return `โ†ช๏ธ Model Fallback cleared: ${selected} (was ${previous})`; @@ -131,8 +132,8 @@ export function resolveFallbackTransition(params: { attempts: RuntimeFallbackAttempt[]; state?: FallbackNoticeState; }): ResolvedFallbackTransition { - const selectedModelRef = `${params.selectedProvider}/${params.selectedModel}`; - const activeModelRef = `${params.activeProvider}/${params.activeModel}`; + const selectedModelRef = formatProviderModelRef(params.selectedProvider, params.selectedModel); + const activeModelRef = formatProviderModelRef(params.activeProvider, params.activeModel); const previousState = { selectedModel: normalizeFallbackModelRef(params.state?.fallbackNoticeSelectedModel), activeModel: normalizeFallbackModelRef(params.state?.fallbackNoticeActiveModel), diff --git a/src/auto-reply/model-runtime.ts b/src/auto-reply/model-runtime.ts index 9421895f25..e43bd66305 100644 --- a/src/auto-reply/model-runtime.ts +++ b/src/auto-reply/model-runtime.ts @@ -1,11 +1,45 @@ import type { SessionEntry } from "../config/sessions.js"; +export function formatProviderModelRef(providerRaw: string, modelRaw: string): string { + const provider = String(providerRaw ?? "").trim(); + const model = String(modelRaw ?? "").trim(); + if (!provider) { + return model; + } + if (!model) { + return provider; + } + const prefix = `${provider}/`; + if (model.toLowerCase().startsWith(prefix.toLowerCase())) { + const normalizedModel = model.slice(prefix.length).trim(); + if (normalizedModel) { + return `${provider}/${normalizedModel}`; + } + } + return `${provider}/${model}`; +} + type ModelRef = { provider: string; model: string; label: string; }; +function normalizeModelWithinProvider(provider: string, modelRaw: string): string { + const model = String(modelRaw ?? "").trim(); + if (!provider || !model) { + return model; + } + const prefix = `${provider}/`; + if (model.toLowerCase().startsWith(prefix.toLowerCase())) { + const withoutPrefix = model.slice(prefix.length).trim(); + if (withoutPrefix) { + return withoutPrefix; + } + } + return model; +} + function normalizeModelRef( rawModel: string, fallbackProvider: string, @@ -25,10 +59,11 @@ function normalizeModelRef( } } const provider = String(fallbackProvider ?? "").trim(); + const dedupedModel = normalizeModelWithinProvider(provider, trimmed); return { provider, - model: trimmed, - label: provider ? `${provider}/${trimmed}` : trimmed, + model: dedupedModel || trimmed, + label: provider ? formatProviderModelRef(provider, dedupedModel || trimmed) : trimmed, }; } diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index e760b66c6a..27bdca0791 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -574,7 +574,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const payloads = res as { text?: string }[]; expect(payloads[0]?.text).toContain("Model Fallback:"); expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5"); - expect(sessionEntry.fallbackNoticeReason).toBe("fireworks/fireworks/minimax-m2p5 rate limit"); + expect(sessionEntry.fallbackNoticeReason).toBe("rate limit"); }); it("does not announce model fallback when verbose is off", async () => { @@ -946,7 +946,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const res = await run(); const firstText = Array.isArray(res) ? res[0]?.text : res?.text; expect(firstText).not.toContain("Model Fallback:"); - expect(sessionEntry.fallbackNoticeReason).toBe("anthropic/claude rate limit"); + expect(sessionEntry.fallbackNoticeReason).toBe("rate limit"); } finally { fallbackSpy.mockRestore(); } @@ -958,7 +958,7 @@ describe("runReplyAgent typing (heartbeat)", () => { updatedAt: Date.now(), fallbackNoticeSelectedModel: "anthropic/claude", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "anthropic/claude rate limit", + fallbackNoticeReason: "rate limit", modelProvider: "deepinfra", model: "moonshotai/Kimi-K2.5", }; @@ -996,7 +996,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const res = await run(); const firstText = Array.isArray(res) ? res[0]?.text : res?.text; expect(firstText).not.toContain("Model Fallback:"); - expect(sessionEntry.fallbackNoticeReason).toBe("anthropic/claude timeout"); + expect(sessionEntry.fallbackNoticeReason).toBe("timeout"); } finally { fallbackSpy.mockRestore(); } @@ -1011,9 +1011,9 @@ describe("runReplyAgent typing (heartbeat)", () => { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath, - fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5", + fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", }; const sessionStore = { main: sessionEntry }; diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index e74881e537..9e47d5dffc 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -85,7 +85,7 @@ describe("/model chat UX", () => { }, }); - expect(reply?.text).toContain("Current: fireworks/fireworks/minimax-m2p5 (selected)"); + expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)"); expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)"); }); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e3683f1e74..f66c39f312 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -205,7 +205,7 @@ describe("buildStatusMessage", () => { model: "claude-haiku-4-5", fallbackNoticeSelectedModel: "openai/gpt-4.1-mini", fallbackNoticeActiveModel: "anthropic/claude-haiku-4-5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", contextTokens: 32_000, }, sessionKey: "agent:main:main", @@ -217,11 +217,10 @@ describe("buildStatusMessage", () => { const normalized = normalizeTestText(text); expect(normalized).toContain("Model: openai/gpt-4.1-mini"); - expect(normalized).toContain("(selected)"); - expect(normalized).toContain("Active: anthropic/claude-haiku-4-5"); - expect(normalized).toContain("(Reason: fireworks/fireworks/minimax-m2p5 rate limit)"); + expect(normalized).toContain("Fallback: anthropic/claude-haiku-4-5"); + expect(normalized).toContain("(rate limit)"); expect(normalized).not.toContain(" - Reason:"); - expect(normalized).not.toContain("Active Reason:"); + expect(normalized).not.toContain("Active:"); expect(normalized).toContain("di_123...abc"); }); @@ -236,9 +235,9 @@ describe("buildStatusMessage", () => { updatedAt: 0, modelProvider: "anthropic", model: "claude-haiku-4-5", - fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5", + fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit", + fallbackNoticeReason: "rate limit", }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -249,9 +248,8 @@ describe("buildStatusMessage", () => { const normalized = normalizeTestText(text); expect(normalized).toContain("Model: openai/gpt-4.1-mini"); - expect(normalized).not.toContain("(selected)"); - expect(normalized).not.toContain("Active:"); - expect(normalized).not.toContain("Reason:"); + expect(normalized).not.toContain("Fallback:"); + expect(normalized).not.toContain("(rate limit)"); }); it("omits active lines when runtime matches selected model", () => { @@ -265,7 +263,7 @@ describe("buildStatusMessage", () => { updatedAt: 0, modelProvider: "openai", model: "gpt-4.1-mini", - fallbackNoticeReason: "openai/gpt-4.1-mini unknown", + fallbackNoticeReason: "unknown", }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -274,8 +272,7 @@ describe("buildStatusMessage", () => { }); const normalized = normalizeTestText(text); - expect(normalized).not.toContain("Active:"); - expect(normalized).not.toContain("Active Reason:"); + expect(normalized).not.toContain("Fallback:"); }); it("keeps provider prefix from configured model", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index bc68e6869c..5ce0f30618 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -41,7 +41,7 @@ import { } from "./commands-registry.js"; import type { CommandCategory } from "./commands-registry.types.js"; import { resolveActiveFallbackState } from "./fallback-state.js"; -import { resolveSelectedAndActiveModel } from "./model-runtime.js"; +import { formatProviderModelRef, resolveSelectedAndActiveModel } from "./model-runtime.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; type AgentDefaults = NonNullable["defaults"]>; @@ -492,19 +492,19 @@ export function buildStatusMessage(args: StatusArgs): string { const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; const selectedModelLabel = modelRefs.selected.label || "unknown"; + const activeModelLabel = formatProviderModelRef(activeProvider, activeModel) || "unknown"; const fallbackState = resolveActiveFallbackState({ - selectedModelRef: `${selectedProvider}/${selectedModel}`, - activeModelRef: `${activeProvider}/${activeModel}`, + selectedModelRef: selectedModelLabel, + activeModelRef: activeModelLabel, state: entry, }); const selectedAuthLabel = selectedAuthLabelValue ? ` ยท ๐Ÿ”‘ ${selectedAuthLabelValue}` : ""; - const modelLine = `๐Ÿง  Model: ${selectedModelLabel}${selectedAuthLabel}${ - fallbackState.active ? " (selected)" : "" - }`; - const activeModelLine = fallbackState.active - ? `๐Ÿง  Active: ${activeProvider}/${activeModel}${ - activeAuthLabelValue ? ` ยท ๐Ÿ”‘ ${activeAuthLabelValue}` : "" - } (Reason: ${fallbackState.reason ?? "selected model unavailable"})` + const modelLine = `๐Ÿง  Model: ${selectedModelLabel}${selectedAuthLabel}`; + const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue; + const fallbackLine = fallbackState.active + ? `โ†ช๏ธ Fallback: ${activeModelLabel}${ + showFallbackAuth ? ` ยท ๐Ÿ”‘ ${activeAuthLabelValue}` : "" + } (${fallbackState.reason ?? "selected model unavailable"})` : null; const commit = resolveCommitHash(); const versionLine = `๐Ÿฆž OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`; @@ -519,7 +519,7 @@ export function buildStatusMessage(args: StatusArgs): string { versionLine, args.timeLine, modelLine, - activeModelLine, + fallbackLine, usageCostLine, `๐Ÿ“š ${contextLine}`, mediaLine, diff --git a/ui/src/ui/app-tool-stream.node.test.ts b/ui/src/ui/app-tool-stream.node.test.ts index c8946f71a2..c07bcd167f 100644 --- a/ui/src/ui/app-tool-stream.node.test.ts +++ b/ui/src/ui/app-tool-stream.node.test.ts @@ -51,13 +51,13 @@ describe("app-tool-stream fallback lifecycle handling", () => { selectedModel: "fireworks/minimax-m2p5", activeProvider: "deepinfra", activeModel: "moonshotai/Kimi-K2.5", - reasonSummary: "fireworks/fireworks/minimax-m2p5 rate limit", + reasonSummary: "rate limit", }, }); - expect(host.fallbackStatus?.selected).toBe("fireworks/fireworks/minimax-m2p5"); + expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5"); expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5"); - expect(host.fallbackStatus?.reason).toBe("fireworks/fireworks/minimax-m2p5 rate limit"); + expect(host.fallbackStatus?.reason).toBe("rate limit"); vi.useRealTimers(); }); diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 89cc3ea302..cd57ee4a59 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -49,6 +49,13 @@ function resolveModelLabel(provider: unknown, model: unknown): string | null { } const providerValue = toTrimmedString(provider); if (providerValue) { + const prefix = `${providerValue}/`; + if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) { + const trimmedModel = modelValue.slice(prefix.length).trim(); + if (trimmedModel) { + return `${providerValue}/${trimmedModel}`; + } + } return `${providerValue}/${modelValue}`; } const slashIndex = modelValue.indexOf("/"); @@ -347,9 +354,10 @@ function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventP if (summaries.length > 0) { return summaries; } - return parseFallbackAttempts(data.attempts).map( - (attempt) => `${attempt.provider}/${attempt.model}: ${attempt.reason}`, - ); + return parseFallbackAttempts(data.attempts).map((attempt) => { + const modelRef = resolveModelLabel(attempt.provider, attempt.model); + return `${modelRef ?? `${attempt.provider}/${attempt.model}`}: ${attempt.reason}`; + }); })(); if (host.fallbackClearTimer != null) { diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 7a01be5286..8c3828a133 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -119,9 +119,9 @@ describe("chat view", () => { renderChat( createProps({ fallbackStatus: { - selected: "fireworks/fireworks/minimax-m2p5", + selected: "fireworks/minimax-m2p5", active: "deepinfra/moonshotai/Kimi-K2.5", - attempts: ["fireworks/fireworks/minimax-m2p5: rate limit"], + attempts: ["fireworks/minimax-m2p5: rate limit"], occurredAt: 900, }, }), @@ -142,7 +142,7 @@ describe("chat view", () => { renderChat( createProps({ fallbackStatus: { - selected: "fireworks/fireworks/minimax-m2p5", + selected: "fireworks/minimax-m2p5", active: "deepinfra/moonshotai/Kimi-K2.5", attempts: [], occurredAt: 0, @@ -164,8 +164,8 @@ describe("chat view", () => { createProps({ fallbackStatus: { phase: "cleared", - selected: "fireworks/fireworks/minimax-m2p5", - active: "fireworks/fireworks/minimax-m2p5", + selected: "fireworks/minimax-m2p5", + active: "fireworks/minimax-m2p5", previous: "deepinfra/moonshotai/Kimi-K2.5", attempts: [], occurredAt: 900, @@ -177,7 +177,7 @@ describe("chat view", () => { const indicator = container.querySelector(".compaction-indicator--fallback-cleared"); expect(indicator).not.toBeNull(); - expect(indicator?.textContent).toContain("Fallback cleared: fireworks/fireworks/minimax-m2p5"); + expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5"); nowSpy.mockRestore(); });