feat(webui): show the actual fallback model (#5017)

This commit is contained in:
chengyongru
2026-07-23 15:57:13 +08:00
committed by GitHub
parent 4188ffc88d
commit 96eb965aae
15 changed files with 352 additions and 8 deletions
+7
View File
@@ -81,6 +81,13 @@ class RuntimeModelUpdatedEvent(OutboundEvent):
model_preset: str | None = None
@dataclass(frozen=True)
class TurnModelUpdatedEvent(OutboundEvent):
"""The fallback model currently handling one chat turn."""
model: str
def outbound_message_for_event(
*,
channel: str,
+32 -1
View File
@@ -26,6 +26,7 @@ from nanobot.bus.outbound_events import (
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
outbound_event_from_message,
outbound_message_for_event,
)
@@ -319,7 +320,7 @@ class WebSocketChannel(BaseChannel):
await self.send_goal_status(chat_id, "running", started_at=t0)
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay goal/run strip state after subscribe (same-process refresh)."""
"""Replay persisted or actively running per-chat state after subscribe."""
await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id)
@@ -805,6 +806,13 @@ class WebSocketChannel(BaseChannel):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
if isinstance(event, TurnModelUpdatedEvent):
if conns:
await self.send_turn_model_updated(
msg.chat_id,
model_name=event.model,
)
return
if isinstance(event, GoalStateSyncEvent):
if conns:
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
@@ -1113,3 +1121,26 @@ class WebSocketChannel(BaseChannel):
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" runtime_model_updated ")
async def send_turn_model_updated(
self,
chat_id: str,
*,
model_name: Any,
) -> None:
"""Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ()))
if (
not conns
or not isinstance(model_name, str)
or not model_name.strip()
):
return
body: dict[str, Any] = {
"event": "turn_model_updated",
"chat_id": chat_id,
"model_name": model_name.strip(),
}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
@@ -20,6 +20,7 @@ from nanobot.bus.outbound_events import (
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket.runtime import (
@@ -1061,6 +1062,33 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
assert payload["model_preset"] == "fast"
@pytest.mark.asyncio
async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
bus = MessageBus()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
chat_one = AsyncMock()
chat_two = AsyncMock()
channel._attach(chat_one, "chat-1")
channel._attach(chat_two, "chat-2")
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=TurnModelUpdatedEvent(model="deepseek/deepseek-chat"),
)
)
payload = json.loads(chat_one.send.call_args.args[0])
assert payload == {
"event": "turn_model_updated",
"chat_id": "chat-1",
"model_name": "deepseek/deepseek-chat",
}
chat_two.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
bus = MessageBus()
@@ -14,6 +14,7 @@ async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
channel.gateway = MagicMock()
channel.gateway.session_manager = MagicMock()
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
channel._turn_models = {}
sent_events = []
@@ -39,6 +40,7 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
channel.gateway = MagicMock()
channel.gateway.session_manager = MagicMock()
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
channel._turn_models = {}
sent_events = []
+18 -3
View File
@@ -1617,9 +1617,14 @@ def _run_gateway(
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
WebuiTurnRoutePolicy,
build_webui_fallback_model_observer,
)
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook
@@ -1651,8 +1656,18 @@ def _run_gateway(
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
runtime_events = RuntimeEventBus()
fallback_model_observer = build_webui_fallback_model_observer(bus)
def _observe_fallback_models(snapshot):
if isinstance(snapshot.provider, FallbackProvider):
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
return snapshot
def _load_gateway_provider_snapshot(*args: Any, **kwargs: Any):
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
try:
provider_snapshot = build_provider_snapshot(config)
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
@@ -1696,7 +1711,7 @@ def _run_gateway(
cron_service=cron,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(config),
provider_snapshot_loader=load_provider_snapshot,
provider_snapshot_loader=_load_gateway_provider_snapshot,
preset_catalog_loader=load_model_preset_catalog,
runtime_events=runtime_events,
turn_delivery_factory=turn_delivery_factory,
+19
View File
@@ -82,6 +82,9 @@ _FALLBACK_ERROR_TOKENS = (
)
FallbackModelObserver = Callable[[str], Awaitable[None]]
class FallbackProvider(LLMProvider):
"""Wrap a primary provider and transparently failover to fallback models.
@@ -108,10 +111,12 @@ class FallbackProvider(LLMProvider):
primary: LLMProvider,
fallback_presets: list[Any],
provider_factory: Callable[[Any], LLMProvider],
fallback_model_observer: FallbackModelObserver | None = None,
):
self._primary = primary
self._fallback_presets = list(fallback_presets)
self._provider_factory = provider_factory
self._fallback_model_observer = fallback_model_observer
self._has_fallbacks = bool(fallback_presets)
self._primary_failures = 0
self._primary_tripped_at: float | None = None
@@ -127,6 +132,10 @@ class FallbackProvider(LLMProvider):
def get_default_model(self) -> str:
return self._primary.get_default_model()
def set_fallback_model_observer(self, observer: FallbackModelObserver | None) -> None:
"""Attach a process-level observer without changing request call signatures."""
self._fallback_model_observer = observer
@property
def supports_progress_deltas(self) -> bool:
return bool(getattr(self._primary, "supports_progress_deltas", False))
@@ -268,6 +277,8 @@ class FallbackProvider(LLMProvider):
)
continue
await self._notify_fallback_model(fallback_model)
original_values = {
name: kwargs.get(name, _MISSING)
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
@@ -315,6 +326,14 @@ class FallbackProvider(LLMProvider):
finish_reason="error",
)
async def _notify_fallback_model(self, model: str) -> None:
if self._fallback_model_observer is None:
return
try:
await self._fallback_model_observer(model)
except Exception:
logger.exception("fallback model observer failed for '{}'", model)
@staticmethod
def _should_fallback(response: LLMResponse) -> bool:
if LLMProvider.is_arrearage_response(response):
+25
View File
@@ -11,6 +11,7 @@ from uuid import uuid4
from loguru import logger
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.turn_delivery import TurnRoute
from nanobot.bus import progress as bus_progress
from nanobot.bus.events import InboundMessage
@@ -20,6 +21,7 @@ from nanobot.bus.outbound_events import (
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
TurnModelUpdatedEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
@@ -33,6 +35,7 @@ from nanobot.bus.runtime_events import (
TurnRunStatusChanged,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.fallback_provider import FallbackModelObserver
from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message
@@ -272,6 +275,28 @@ class WebuiTurnRoutePolicy:
return replace(route, metadata=metadata, publish_lifecycle=True)
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
"""Translate provider fallback choices into chat-scoped WebUI events."""
async def _publish(model: str) -> None:
context = current_request_context()
if context is None or context.channel != "websocket":
return
chat_id = str(context.chat_id or "").strip()
if not chat_id:
return
await bus.publish_outbound(
outbound_message_for_event(
channel=context.channel,
chat_id=chat_id,
event=TurnModelUpdatedEvent(model=model),
metadata=context.metadata,
)
)
return _publish
@dataclass
class WebuiTurnCoordinator:
"""Translate generic runtime events into WebUI/WebSocket wire messages."""
+24
View File
@@ -285,6 +285,30 @@ class TestFallbackOnPrimaryError:
assert primary.chat_calls[0]["model"] == "primary-model"
assert fallback.chat_calls[0]["model"] == "fallback-a"
@pytest.mark.asyncio
async def test_reports_the_fallback_model_before_its_request(self) -> None:
primary = _FakeProvider("primary", _error_response())
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fallback_models: list[str] = []
async def _observe(model: str) -> None:
fallback_models.append(model)
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a", provider="backup")],
provider_factory=MagicMock(return_value=fallback),
fallback_model_observer=_observe,
)
result = await fb.chat_with_retry(
messages=[{"role": "user", "content": "hi"}],
model="primary-model",
)
assert result.content == "fallback ok"
assert fallback_models == ["fallback-a"]
@pytest.mark.asyncio
async def test_logs_primary_error_before_fallback(self) -> None:
primary = _FakeProvider("primary", _error_response("primary overloaded"))
+37 -1
View File
@@ -4,8 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import GoalStatusEvent
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
from nanobot.session import webui_turns as wth
@@ -69,3 +70,38 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
await wth.publish_turn_run_status(bus, msg, "running")
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
@pytest.mark.asyncio
async def test_fallback_model_is_scoped_to_its_websocket_chat() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
observer = wth.build_webui_fallback_model_observer(bus)
with request_context(
RequestContext(
channel="websocket",
chat_id="chat-model",
metadata={"webui": True},
)
):
await observer("deepseek/deepseek-chat")
outbound = bus.publish_outbound.await_args.args[0]
assert outbound.channel == "websocket"
assert outbound.chat_id == "chat-model"
assert outbound.metadata == {"webui": True}
assert isinstance(outbound.event, TurnModelUpdatedEvent)
assert outbound.event.model == "deepseek/deepseek-chat"
@pytest.mark.asyncio
async def test_fallback_model_ignores_non_websocket_requests() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
observer = wth.build_webui_fallback_model_observer(bus)
with request_context(RequestContext(channel="telegram", chat_id="chat-model")):
await observer("fallback")
bus.publish_outbound.assert_not_awaited()
@@ -170,6 +170,7 @@ interface ThreadComposerProps {
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
@@ -815,6 +816,7 @@ export function ThreadComposer({
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
fallbackModelName = null,
onModelBadgeClick,
variant = "thread",
slashCommands = [],
@@ -2073,6 +2075,7 @@ export function ThreadComposer({
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
@@ -2361,6 +2364,7 @@ function ComposerModelBadge({
provider,
providerLabel,
needsSetup,
fallbackModelName,
isHero,
onClick,
}: {
@@ -2368,6 +2372,7 @@ function ComposerModelBadge({
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}) {
@@ -2381,11 +2386,12 @@ function ComposerModelBadge({
return (
<Container
title={title}
data-fallback={fallbackModelName ? "true" : undefined}
title={fallbackModelName || title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
@@ -333,6 +333,7 @@ export function ThreadShell({
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, ingressLimits, modelName, token } = useClient();
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const cliApps = useInstalledSettingItems({
@@ -379,6 +380,7 @@ export function ThreadShell({
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
setFallbackModelName(null);
onTurnEnd?.();
}, [onTurnEnd]);
const {
@@ -519,6 +521,18 @@ export function ThreadShell({
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId) {
setFallbackModelName(null);
return;
}
setFallbackModelName(null);
return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return;
setFallbackModelName(event.model_name);
});
}, [chatId, client]);
useEffect(() => {
if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
@@ -680,6 +694,7 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendAttachment[], options?: SendOptions) => {
setFallbackModelName(null);
setScrollToLatestUserPromptSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@@ -808,6 +823,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
@@ -845,6 +861,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
+32
View File
@@ -124,6 +124,38 @@
}
@layer utilities {
.composer-model-badge {
position: relative;
isolation: isolate;
overflow: hidden;
}
.composer-model-badge::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background-color: rgb(236 141 49);
opacity: 0;
transition: opacity 600ms ease-in-out;
}
.composer-model-badge[data-fallback="true"]::before {
opacity: 1;
}
.composer-model-badge > * {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.composer-model-badge::before {
transition-duration: 150ms;
}
}
.host-drag-region {
-webkit-app-region: drag;
}
+5
View File
@@ -1087,6 +1087,11 @@ export type InboundEvent =
model_name: string;
model_preset?: string | null;
}
| {
event: "turn_model_updated";
chat_id: string;
model_name: string;
}
| ({
event: "turn_end";
chat_id: string;
+24
View File
@@ -357,6 +357,30 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
});
it("dispatches turn model updates to the active chat", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
client.onChat("chat-a", chatHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "turn_model_updated",
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
});
expect(chatHandler).toHaveBeenCalledWith({
event: "turn_model_updated",
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
});
});
it("dispatches session updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
+74 -1
View File
@@ -14,13 +14,23 @@ const HERO_GREETING_PATTERN =
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
const runtimeModelHandlers = new Set<
(modelName: string | null, modelPreset?: string | null) => void
>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onRuntimeModelUpdate: () => () => {},
onRuntimeModelUpdate: (
handler: (modelName: string | null, modelPreset?: string | null) => void,
) => {
runtimeModelHandlers.add(handler);
return () => {
runtimeModelHandlers.delete(handler);
};
},
getRunStartedAt: () => null,
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
@@ -55,6 +65,9 @@ function makeClient() {
}
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
},
_emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null) {
for (const h of runtimeModelHandlers) h(modelName, modelPreset);
},
_emitSessionUpdate(chatId: string, scope?: string) {
for (const h of sessionUpdateHandlers) h(chatId, scope);
},
@@ -411,6 +424,66 @@ describe("ThreadShell", () => {
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("highlights the configured model badge without replacing the preset label", async () => {
const client = makeClient();
render(wrap(
client,
<ThreadShell
session={session("fallback-model")}
title="Fallback model"
onToggleSidebar={() => {}}
settingsSnapshot={modelSettings("openai-codex/gpt-5.5", "openai_codex")}
/>,
"openai-codex/gpt-5.5",
));
expect(await screen.findByText("gpt-5.5")).toBeInTheDocument();
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
expect(configuredBadge).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback");
act(() => {
client._emitChat("fallback-model", {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat",
});
});
const logo = screen.getByTestId("composer-model-logo-openai_codex");
const badge = logo.parentElement;
expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge);
expect(screen.getByText("gpt-5.5")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).toHaveAttribute(
"title",
"deepseek/deepseek-chat",
);
expect(logo).not.toHaveAttribute("data-fallback");
act(() => {
client._emitChat("fallback-model", {
event: "turn_end",
chat_id: "fallback-model",
});
});
await waitFor(() => {
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback");
});
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toHaveAttribute("title", "gpt-5.5 · OpenAI Codex");
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
});
it("opens model settings from the unconfigured model badge", async () => {
const client = makeClient();
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");