From 1473f074d642317d9344ab4993d9082f218d5d80 Mon Sep 17 00:00:00 2001 From: xiami762 Date: Sat, 25 Apr 2026 12:18:11 +0800 Subject: [PATCH 01/87] fix(langfuse): restore trace capture for SDK v4 Use the OTEL-based Langfuse observation flow so session traces actually reach Langfuse again, and record full model/tool inputs and outputs for later debugging and analysis. Made-with: Cursor --- flocks/session/runner.py | 143 +++++++++---- flocks/session/streaming/stream_processor.py | 6 +- flocks/utils/langfuse.py | 190 +++++++++++++++--- .../test_langfuse_observability.py | 128 +++++++++++- .../session/test_runner_langfuse_payloads.py | 81 ++++++++ tests/session/test_stream_processor.py | 62 ++++++ 6 files changed, 530 insertions(+), 80 deletions(-) create mode 100644 tests/session/test_runner_langfuse_payloads.py diff --git a/flocks/session/runner.py b/flocks/session/runner.py index f5731ab00..49975ad0a 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -1046,6 +1046,66 @@ def _build_tokens_update(stream_usage: Optional[Dict[str, int]]) -> Optional[Dic }, } + @staticmethod + def _serialize_chat_message_for_langfuse(message: ChatMessage) -> Dict[str, Any]: + """Serialize the exact provider-bound message payload for Langfuse.""" + if hasattr(message, "model_dump"): + return message.model_dump(exclude_none=True) + return { + "role": message.role, + "content": message.content, + "reasoning": getattr(message, "reasoning", None), + "tool_calls": getattr(message, "tool_calls", None), + "tool_call_id": getattr(message, "tool_call_id", None), + "name": getattr(message, "name", None), + "custom_settings": getattr(message, "custom_settings", {}), + } + + @classmethod + def _build_langfuse_request_payload( + cls, + *, + step: int, + messages: List[ChatMessage], + request_tools: Optional[List[Dict[str, Any]]], + available_tools: List[Dict[str, Any]], + provider_options: Dict[str, Any], + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "step": step, + "messages": [cls._serialize_chat_message_for_langfuse(message) for message in messages], + "provider_options": provider_options, + } + if request_tools is not None: + payload["request_tools"] = request_tools + if available_tools: + payload["available_tools"] = available_tools + return payload + + @staticmethod + def _build_langfuse_response_payload( + *, + action: str, + content: str, + reasoning: str, + finish_reason: Optional[str], + tool_calls: List[ToolCall], + ) -> Dict[str, Any]: + return { + "action": action, + "content": content, + "reasoning": reasoning, + "finish_reason": finish_reason, + "tool_calls": [ + { + "id": tool_call.id, + "name": tool_call.name, + "arguments": tool_call.arguments, + } + for tool_call in tool_calls + ], + } + def _resolve_usage_pricing(self) -> Optional[Any]: """Resolve pricing config for the current provider/model pair.""" from flocks.provider.usage_service import resolve_usage_pricing @@ -1829,6 +1889,7 @@ async def _call_llm( # Build provider options (thinking / reasoning / max_tokens) from flocks.provider.options import build_provider_options provider_options = build_provider_options(self.provider_id, self.model_id) + provider_tools = None if self._should_use_text_tool_call_mode() else (tools if tools else None) # Clean up any leftover reasoning state from a previous (failed) call if hasattr(self, '_current_reasoning_id'): @@ -1847,13 +1908,6 @@ async def _call_llm( trace_ctx = None generation_ctx = None try: - input_preview = [] - for _msg in messages[-12:]: - _mc = _msg.content or "" - input_preview.append( - {"role": _msg.role, "chars": len(_mc), "preview": _mc[:240]} - ) - trace_tags = [ f"session:{self.session.id}", f"step:{self._step}", @@ -1861,36 +1915,43 @@ async def _call_llm( f"agent:{agent.name}", f"provider:{self.provider_id}", ] + request_payload = self._build_langfuse_request_payload( + step=self._step, + messages=messages, + request_tools=provider_tools, + available_tools=tools, + provider_options=provider_options, + ) trace_ctx = trace_scope( name="SessionRunner.step", session_id=self.session.id, tags=trace_tags, - input={ - "step": self._step, - "message_count": len(messages), - "tool_count": len(tools), - "last_user_preview": next( - ((m.content or "")[:280] for m in reversed(messages) if m.role == "user"), - "", - ), - }, + input=request_payload, metadata={ "provider_id": self.provider_id, "model_id": self.model_id, "agent": agent.name, "workspace": self.session.directory, + "message_count": len(messages), + "available_tool_count": len(tools), + "request_tool_count": len(provider_tools or []), + "tool_transport": "provider_param" if provider_tools is not None else "text_prompt", }, ) generation_ctx = generation_scope( parent=trace_ctx.observation, name="LLM.generate", model=self.model_id, - input=input_preview, + input=request_payload, metadata={ "provider_id": self.provider_id, "session_id": self.session.id, "step": self._step, - "tool_names": [t.get("function", {}).get("name", "") for t in tools][:50], + "agent": agent.name, + "workspace": self.session.directory, + "available_tool_count": len(tools), + "request_tool_count": len(provider_tools or []), + "tool_transport": "provider_param" if provider_tools is not None else "text_prompt", }, ) processor._langfuse_generation = generation_ctx.observation @@ -1923,7 +1984,6 @@ async def _call_llm( stream_usage: Optional[Dict[str, int]] = None # Stream response and convert chunks to events - provider_tools = None if self._should_use_text_tool_call_mode() else (tools if tools else None) if provider_tools is None and tools: log.info("runner.text_tool_call_mode.enabled", { "session_id": self.session.id, @@ -2103,28 +2163,26 @@ async def _call_llm( ) for tc_state in processor.tool_calls.values() ] + finish_reason = processor.get_finish_reason() if tool_calls_for_result: + response_payload = self._build_langfuse_response_payload( + action="continue", + content=content, + reasoning=reasoning, + finish_reason=finish_reason, + tool_calls=tool_calls_for_result, + ) self._end_observability( generation_ctx, trace_ctx, - output={ - "content_preview": content[:600], - "content_chars": len(content), - "reasoning_chars": len(reasoning), - "tool_calls": [{"id": tc.id, "name": tc.name} for tc in tool_calls_for_result[:30]], - }, + output=response_payload, usage=stream_usage, metadata={ - "finish_reason": processor.get_finish_reason(), + "finish_reason": finish_reason, "status": "continue_with_tools", "tool_call_count": len(tool_calls_for_result), }, - trace_output={ - "status": "ok", - "next_action": "continue", - "finish_reason": processor.get_finish_reason(), - "tool_call_count": len(tool_calls_for_result), - }, + trace_output=response_payload, ) return StepResult( action="continue", @@ -2133,24 +2191,23 @@ async def _call_llm( usage=stream_usage, ) + response_payload = self._build_langfuse_response_payload( + action="stop", + content=content, + reasoning=reasoning, + finish_reason=finish_reason, + tool_calls=tool_calls_for_result, + ) self._end_observability( generation_ctx, trace_ctx, - output={ - "content_preview": content[:600], - "content_chars": len(content), - "reasoning_chars": len(reasoning), - }, + output=response_payload, usage=stream_usage, metadata={ - "finish_reason": processor.get_finish_reason(), + "finish_reason": finish_reason, "status": "stop", "tool_call_count": 0, }, - trace_output={ - "status": "ok", - "next_action": "stop", - "finish_reason": processor.get_finish_reason(), - }, + trace_output=response_payload, ) return StepResult(action="stop", content=content, usage=stream_usage) diff --git a/flocks/session/streaming/stream_processor.py b/flocks/session/streaming/stream_processor.py index 0d52b00ab..4323b67f7 100644 --- a/flocks/session/streaming/stream_processor.py +++ b/flocks/session/streaming/stream_processor.py @@ -743,14 +743,10 @@ async def _persist_running_metadata(): "tool_name": tool_name, "success": result.success, }) - output_preview = result.output if result.success else result.error - if isinstance(output_preview, str): - output_preview = output_preview[:600] - try: if tool_span_ctx is not None: end_kwargs: Dict[str, Any] = { - "output": output_preview, + "output": result.output if result.success else result.error, "metadata": { "success": result.success, "title": result.title, diff --git a/flocks/utils/langfuse.py b/flocks/utils/langfuse.py index 53564a43b..52d7939f9 100644 --- a/flocks/utils/langfuse.py +++ b/flocks/utils/langfuse.py @@ -42,17 +42,47 @@ def end(self, **_: Any) -> None: "langfuse_current_observation", default=None, ) +_CAPTURE_MODE_ENV = "FLOCKS_LANGFUSE_CAPTURE_MODE" +_MAX_CHARS_ENV = "FLOCKS_LANGFUSE_MAX_CHARS" +_DEFAULT_CAPTURE_MODE = "full" +_DEFAULT_MAX_CHARS = 8000 +_VALID_CAPTURE_MODES = {"full", "truncated"} +_TRACE_NAME_ATTR = "langfuse.trace.name" +_TRACE_USER_ID_ATTR = "user.id" +_TRACE_SESSION_ID_ATTR = "session.id" +_TRACE_TAGS_ATTR = "langfuse.trace.tags" def _filter_none(values: Dict[str, Any]) -> Dict[str, Any]: return {k: v for k, v in values.items() if v is not None} -def _truncate_value(value: Any, max_chars: int = 8000) -> Any: +def _get_capture_mode() -> str: + mode = os.getenv(_CAPTURE_MODE_ENV, _DEFAULT_CAPTURE_MODE).strip().lower() + if mode in _VALID_CAPTURE_MODES: + return mode + return _DEFAULT_CAPTURE_MODE + + +def _get_capture_max_chars() -> int: + raw_value = os.getenv(_MAX_CHARS_ENV, str(_DEFAULT_MAX_CHARS)).strip() + try: + max_chars = int(raw_value) + except (TypeError, ValueError): + return _DEFAULT_MAX_CHARS + if max_chars <= 0: + return _DEFAULT_MAX_CHARS + return max_chars + + +def _truncate_value(value: Any, max_chars: Optional[int] = None) -> Any: if isinstance(value, str): - if len(value) <= max_chars: + if _get_capture_mode() == "full": return value - return value[:max_chars] + f"...[truncated:{len(value) - max_chars}]" + limit = max_chars if max_chars is not None else _get_capture_max_chars() + if len(value) <= limit: + return value + return value[:limit] + f"...[truncated:{len(value) - limit}]" return value @@ -67,6 +97,58 @@ def _sanitize_payload(value: Any) -> Any: return _truncate_value(value) +def _set_trace_attribute(observation: Any, key: str, value: Any) -> None: + otel_span = getattr(observation, "_otel_span", None) + if otel_span is None or not hasattr(otel_span, "is_recording"): + return + try: + if otel_span.is_recording(): + otel_span.set_attribute(key, value) + except Exception: + return + + +def _propagate_trace_dimensions( + observation: Any, + *, + trace_name: Optional[str] = None, + session_id: Optional[str] = None, + user_id: Optional[str] = None, + tags: Optional[list[str]] = None, + parent: Any = None, +) -> Any: + inherited_trace_name = trace_name + inherited_session_id = session_id + inherited_user_id = user_id + inherited_tags = tags + + parent_span = getattr(parent, "_otel_span", None) + parent_attrs = getattr(parent_span, "attributes", None) if parent_span is not None else None + if parent_attrs: + if inherited_trace_name is None: + inherited_trace_name = parent_attrs.get(_TRACE_NAME_ATTR) + if inherited_session_id is None: + inherited_session_id = parent_attrs.get(_TRACE_SESSION_ID_ATTR) + if inherited_user_id is None: + inherited_user_id = parent_attrs.get(_TRACE_USER_ID_ATTR) + if inherited_tags is None: + parent_tags = parent_attrs.get(_TRACE_TAGS_ATTR) + if isinstance(parent_tags, list): + inherited_tags = parent_tags + elif isinstance(parent_tags, tuple): + inherited_tags = list(parent_tags) + + if inherited_trace_name is not None: + _set_trace_attribute(observation, _TRACE_NAME_ATTR, inherited_trace_name) + if inherited_session_id is not None: + _set_trace_attribute(observation, _TRACE_SESSION_ID_ATTR, inherited_session_id) + if inherited_user_id is not None: + _set_trace_attribute(observation, _TRACE_USER_ID_ATTR, inherited_user_id) + if inherited_tags is not None: + _set_trace_attribute(observation, _TRACE_TAGS_ATTR, inherited_tags) + return observation + + def initialize() -> None: """Initialize Langfuse client once (no-op when unavailable).""" global _client, _initialized @@ -140,26 +222,48 @@ def create_trace( ) try: # Old SDKs may expose .trace(), newer SDKs are OTEL-native and expose - # .start_span() / .start_observation(). + # .start_observation(). if hasattr(client, "trace"): return client.trace(**payload) - trace_obs = client.start_span( - name=name, - input=payload.get("input"), - metadata=payload.get("metadata"), - ) - # Best-effort enrich current trace with session/user dimensions. - try: - client.update_current_trace( + if hasattr(client, "start_observation"): + trace_metadata = dict(payload.get("metadata") or {}) + if session_id is not None: + trace_metadata.setdefault("session_id", session_id) + if user_id is not None: + trace_metadata.setdefault("user_id", user_id) + if tags is not None: + trace_metadata.setdefault("tags", tags) + trace_obs = client.start_observation( + name=name, + as_type="span", + input=payload.get("input"), + metadata=trace_metadata or None, + ) + return _propagate_trace_dimensions( + trace_obs, + trace_name=name, session_id=session_id, user_id=user_id, tags=tags, + ) + if hasattr(client, "start_span"): + trace_obs = client.start_span( + name=name, input=payload.get("input"), metadata=payload.get("metadata"), ) - except Exception: - pass - return trace_obs + # Best-effort enrich current trace with session/user dimensions. + try: + client.update_current_trace( + session_id=session_id, + user_id=user_id, + tags=tags, + input=payload.get("input"), + metadata=payload.get("metadata"), + ) + except Exception: + pass + return trace_obs except Exception as exc: log.warn("langfuse.trace_failed", {"error": str(exc), "name": name}) return _NoopObservation("trace") @@ -189,15 +293,21 @@ def create_generation( ) try: if parent and hasattr(parent, "generation"): - return parent.generation(**payload) + return _propagate_trace_dimensions(parent.generation(**payload), parent=parent) if parent and hasattr(parent, "start_generation"): - return parent.start_generation(**payload) + return _propagate_trace_dimensions(parent.start_generation(**payload), parent=parent) if parent and hasattr(parent, "start_observation"): - return parent.start_observation(as_type="generation", **payload) + return _propagate_trace_dimensions( + parent.start_observation(as_type="generation", **payload), + parent=parent, + ) if hasattr(client, "start_generation"): - return client.start_generation(**payload) + return _propagate_trace_dimensions(client.start_generation(**payload), parent=parent) if hasattr(client, "start_observation"): - return client.start_observation(as_type="generation", **payload) + return _propagate_trace_dimensions( + client.start_observation(as_type="generation", **payload), + parent=parent, + ) except Exception as exc: log.warn("langfuse.generation_failed", {"error": str(exc), "name": name}) return _NoopObservation("generation") @@ -225,15 +335,21 @@ def create_span( ) try: if parent and hasattr(parent, "span"): - return parent.span(**payload) + return _propagate_trace_dimensions(parent.span(**payload), parent=parent) if parent and hasattr(parent, "start_span"): - return parent.start_span(**payload) + return _propagate_trace_dimensions(parent.start_span(**payload), parent=parent) if parent and hasattr(parent, "start_observation"): - return parent.start_observation(as_type="span", **payload) + return _propagate_trace_dimensions( + parent.start_observation(as_type="span", **payload), + parent=parent, + ) if hasattr(client, "start_span"): - return client.start_span(**payload) + return _propagate_trace_dimensions(client.start_span(**payload), parent=parent) if hasattr(client, "start_observation"): - return client.start_observation(as_type="span", **payload) + return _propagate_trace_dimensions( + client.start_observation(as_type="span", **payload), + parent=parent, + ) except Exception as exc: log.warn("langfuse.span_failed", {"error": str(exc), "name": name}) return _NoopObservation("span") @@ -267,20 +383,32 @@ def end_observation( observation.end(**payload) return except TypeError: - try: - observation.end() - return - except Exception: - pass + pass except Exception: pass try: if hasattr(observation, "update"): - observation.update(**payload) + update_payload = dict(payload) + if usage: + update_payload["usage_details"] = usage + try: + observation.update(**update_payload) + except TypeError: + fallback_payload = dict(payload) + if usage: + fallback_payload["usage"] = usage + fallback_payload.pop("usage_details", None) + observation.update(**fallback_payload) except Exception as exc: log.debug("langfuse.end_fallback_update_failed", {"error": str(exc)}) + try: + if hasattr(observation, "end"): + observation.end() + except Exception as exc: + log.debug("langfuse.end_fallback_end_failed", {"error": str(exc)}) + def is_active() -> bool: """Return True when Langfuse is initialized and has a live client.""" diff --git a/tests/observability/test_langfuse_observability.py b/tests/observability/test_langfuse_observability.py index 8473ac802..8dcc02ef8 100644 --- a/tests/observability/test_langfuse_observability.py +++ b/tests/observability/test_langfuse_observability.py @@ -27,6 +27,15 @@ def trace(self, **kwargs): return _FakeObservation("trace", kwargs) +class _NewSdkTraceClient: + def __init__(self): + self.start_observation_payload = None + + def start_observation(self, **kwargs): + self.start_observation_payload = kwargs + return _TrackingObservation("trace", kwargs) + + class _FakeObservation: """Fake Langfuse observation with end/generation/span support.""" @@ -45,6 +54,41 @@ def end(self, **kwargs): self.end_payload = kwargs +class _TrackingSpan: + def __init__(self) -> None: + self.attributes = {} + + def is_recording(self): + return True + + def set_attribute(self, key, value): + self.attributes[key] = value + + +class _TrackingObservation(_FakeObservation): + def __init__(self, kind: str, payload: dict): + super().__init__(kind, payload) + self._otel_span = _TrackingSpan() + + def generation(self, **kwargs): + return _TrackingObservation("generation", kwargs) + + def span(self, **kwargs): + return _TrackingObservation("span", kwargs) + + +class _NewSdkLikeObservation: + def __init__(self) -> None: + self.update_payload = None + self.end_calls = 0 + + def update(self, **kwargs): + self.update_payload = kwargs + + def end(self): + self.end_calls += 1 + + def test_create_span_uses_current_observation(monkeypatch): monkeypatch.setattr(lf, "_get_client", lambda: object()) parent = _FakeParent() @@ -92,6 +136,55 @@ def test_create_trace_forwards_tags(monkeypatch): assert client.trace_payload["tags"] == ["session:s1", "step:2", "session_step:s1:2"] +def test_create_trace_uses_start_observation_for_new_sdk(monkeypatch): + client = _NewSdkTraceClient() + monkeypatch.setattr(lf, "_get_client", lambda: client) + + obs = lf.create_trace( + name="SessionRunner.step", + session_id="s1", + user_id="u1", + tags=["session:s1", "step:2"], + input={"step": 2}, + metadata={"provider_id": "openai"}, + ) + + assert obs.kind == "trace" + assert client.start_observation_payload is not None + assert client.start_observation_payload["as_type"] == "span" + assert client.start_observation_payload["input"] == {"step": 2} + assert client.start_observation_payload["metadata"]["provider_id"] == "openai" + assert client.start_observation_payload["metadata"]["session_id"] == "s1" + assert client.start_observation_payload["metadata"]["user_id"] == "u1" + assert client.start_observation_payload["metadata"]["tags"] == ["session:s1", "step:2"] + assert obs._otel_span.attributes["langfuse.trace.name"] == "SessionRunner.step" + assert obs._otel_span.attributes["session.id"] == "s1" + assert obs._otel_span.attributes["user.id"] == "u1" + assert obs._otel_span.attributes["langfuse.trace.tags"] == ["session:s1", "step:2"] + + +def test_generation_and_span_inherit_trace_dimensions_from_parent(monkeypatch): + monkeypatch.setattr(lf, "_get_client", lambda: object()) + + parent = _TrackingObservation("trace", {"name": "trace"}) + parent._otel_span.attributes["langfuse.trace.name"] = "SessionRunner.step" + parent._otel_span.attributes["session.id"] = "s1" + parent._otel_span.attributes["user.id"] = "u1" + parent._otel_span.attributes["langfuse.trace.tags"] = ["session:s1", "step:2"] + + gen = lf.create_generation(parent=parent, name="LLM.generate", model="gpt-5", input={"x": 1}) + span = lf.create_span(parent=parent, name="Tool.execute.read", input={"path": "/tmp/a"}) + + assert gen._otel_span.attributes["langfuse.trace.name"] == "SessionRunner.step" + assert gen._otel_span.attributes["session.id"] == "s1" + assert gen._otel_span.attributes["user.id"] == "u1" + assert gen._otel_span.attributes["langfuse.trace.tags"] == ["session:s1", "step:2"] + assert span._otel_span.attributes["langfuse.trace.name"] == "SessionRunner.step" + assert span._otel_span.attributes["session.id"] == "s1" + assert span._otel_span.attributes["user.id"] == "u1" + assert span._otel_span.attributes["langfuse.trace.tags"] == ["session:s1", "step:2"] + + def test_initialize_supports_langfuse_base_url(monkeypatch): class _FakeLangfuseClient: def __init__(self, **kwargs): @@ -156,6 +249,29 @@ def test_end_observation_passes_usage(monkeypatch): assert gen_obs.end_payload.get("output") == "result" +def test_end_observation_updates_before_end_for_new_sdk(): + """Langfuse v4-style observations require update(...), then end().""" + obs = _NewSdkLikeObservation() + usage = {"prompt_tokens": 100, "completion_tokens": 50} + + lf.end_observation( + obs, + output={"content": "result"}, + metadata={"status": "ok"}, + usage=usage, + level="ERROR", + status_message="done", + ) + + assert obs.update_payload is not None + assert obs.update_payload["output"] == {"content": "result"} + assert obs.update_payload["metadata"] == {"status": "ok"} + assert obs.update_payload["usage_details"] == usage + assert obs.update_payload["level"] == "ERROR" + assert obs.update_payload["status_message"] == "done" + assert obs.end_calls == 1 + + def test_scope_end_is_idempotent(): """Calling end() twice on a scope should not raise.""" noop = lf._NoopObservation("test") @@ -164,11 +280,21 @@ def test_scope_end_is_idempotent(): scope.end(output="second") -def test_sanitize_truncates_long_strings(): +def test_sanitize_keeps_full_strings_in_full_mode(monkeypatch): + long_str = "a" * 10000 + monkeypatch.setenv("FLOCKS_LANGFUSE_CAPTURE_MODE", "full") + result = lf._sanitize_payload(long_str) + assert result == long_str + + +def test_sanitize_truncates_long_strings_in_truncated_mode(monkeypatch): long_str = "a" * 10000 + monkeypatch.setenv("FLOCKS_LANGFUSE_CAPTURE_MODE", "truncated") + monkeypatch.setenv("FLOCKS_LANGFUSE_MAX_CHARS", "128") result = lf._sanitize_payload(long_str) assert len(result) < 10000 assert "truncated" in result + assert result.startswith("a" * 128) def test_observation_scope_exception_handling(): diff --git a/tests/session/test_runner_langfuse_payloads.py b/tests/session/test_runner_langfuse_payloads.py new file mode 100644 index 000000000..129ee8b3a --- /dev/null +++ b/tests/session/test_runner_langfuse_payloads.py @@ -0,0 +1,81 @@ +from flocks.provider.provider import ChatMessage +from flocks.session.runner import SessionRunner, ToolCall + + +def test_build_langfuse_request_payload_keeps_full_messages_and_system_prompt() -> None: + tools = [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": {"type": "object"}, + }, + } + ] + messages = [ + ChatMessage(role="system", content="system prompt"), + ChatMessage(role="user", content="user question"), + ChatMessage( + role="assistant", + content="calling tool", + tool_calls=[ + { + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\":\"/tmp/demo.txt\"}", + }, + } + ], + ), + ] + + payload = SessionRunner._build_langfuse_request_payload( + step=3, + messages=messages, + request_tools=tools, + available_tools=tools, + provider_options={"temperature": 0.1, "max_tokens": 1024}, + ) + + assert payload["step"] == 3 + assert payload["messages"][0]["role"] == "system" + assert payload["messages"][0]["content"] == "system prompt" + assert payload["messages"][1]["content"] == "user question" + assert payload["messages"][2]["tool_calls"][0]["function"]["arguments"] == "{\"path\":\"/tmp/demo.txt\"}" + assert payload["request_tools"] == tools + assert payload["available_tools"] == tools + assert payload["provider_options"] == {"temperature": 0.1, "max_tokens": 1024} + + +def test_build_langfuse_response_payload_keeps_full_content_reasoning_and_tool_arguments() -> None: + full_content = "assistant output " * 80 + full_reasoning = "reasoning output " * 60 + tool_calls = [ + ToolCall( + id="call_1", + name="read_file", + arguments={"path": "/tmp/demo.txt", "offset": 1, "limit": 1000}, + ) + ] + + payload = SessionRunner._build_langfuse_response_payload( + action="continue", + content=full_content, + reasoning=full_reasoning, + finish_reason="tool_calls", + tool_calls=tool_calls, + ) + + assert payload["action"] == "continue" + assert payload["content"] == full_content + assert payload["reasoning"] == full_reasoning + assert payload["finish_reason"] == "tool_calls" + assert payload["tool_calls"] == [ + { + "id": "call_1", + "name": "read_file", + "arguments": {"path": "/tmp/demo.txt", "offset": 1, "limit": 1000}, + } + ] diff --git a/tests/session/test_stream_processor.py b/tests/session/test_stream_processor.py index a2e7b4603..291f9abdc 100644 --- a/tests/session/test_stream_processor.py +++ b/tests/session/test_stream_processor.py @@ -371,6 +371,68 @@ async def test_tool_start_callback_called(self): callback.assert_called_once() + @pytest.mark.asyncio + async def test_tool_span_records_full_output(self): + proc = _make_processor() + proc._langfuse_generation = object() + long_output = "tool output " * 120 + span_ctx = MagicMock() + + successful_result = ToolResult( + success=True, + output=long_output, + title="bash", + metadata={}, + ) + + with ( + patch("flocks.session.streaming.stream_processor.Message.store_part", new=AsyncMock()), + patch("flocks.session.streaming.stream_processor.Message.update_part", new=AsyncMock()), + patch( + "flocks.session.streaming.stream_processor.ToolRegistry.execute", + new=AsyncMock(return_value=successful_result), + ), + patch("flocks.session.streaming.stream_processor.span_scope", return_value=span_ctx), + ): + await proc.process_event(ToolInputStartEvent(id="tc_span_output", tool_name="bash")) + await proc.process_event( + ToolCallEvent(tool_call_id="tc_span_output", tool_name="bash", input={"command": "ls -la"}) + ) + + assert span_ctx.end.call_count == 1 + assert span_ctx.end.call_args.kwargs["output"] == long_output + + @pytest.mark.asyncio + async def test_tool_span_records_full_error(self): + proc = _make_processor() + proc._langfuse_generation = object() + long_error = "tool error " * 120 + span_ctx = MagicMock() + + failed_result = ToolResult( + success=False, + error=long_error, + metadata={}, + ) + + with ( + patch("flocks.session.streaming.stream_processor.Message.store_part", new=AsyncMock()), + patch("flocks.session.streaming.stream_processor.Message.update_part", new=AsyncMock()), + patch( + "flocks.session.streaming.stream_processor.ToolRegistry.execute", + new=AsyncMock(return_value=failed_result), + ), + patch("flocks.session.streaming.stream_processor.span_scope", return_value=span_ctx), + ): + await proc.process_event(ToolInputStartEvent(id="tc_span_error", tool_name="bash")) + await proc.process_event( + ToolCallEvent(tool_call_id="tc_span_error", tool_name="bash", input={"command": "false"}) + ) + + assert span_ctx.end.call_count == 1 + assert span_ctx.end.call_args.kwargs["output"] == long_error + assert span_ctx.end.call_args.kwargs["level"] == "ERROR" + @pytest.mark.asyncio async def test_tool_error_falls_back_to_metadata_output(self): event_callback = AsyncMock() From 55e01524b313926be0e9e85935363643c92af157 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 19:34:36 +0800 Subject: [PATCH 02/87] feat(webui): add runtime model failover --- flocks/config/config.py | 76 ++ flocks/config/config_writer.py | 92 ++ flocks/provider/provider.py | 119 +- flocks/server/routes/default_model.py | 116 +- flocks/server/routes/session.py | 200 +++- flocks/session/message.py | 76 +- flocks/session/runner.py | 263 ++++- flocks/session/session.py | 15 + flocks/session/session_loop.py | 500 +++++++- tests/agent/test_unified_session_loop.py | 216 ++++ tests/channel/test_channel.py | 1 + tests/config/test_config_writer.py | 97 ++ .../test_provider_apply_config_idempotent.py | 61 + .../routes/test_default_model_fallbacks.py | 305 +++++ tests/server/routes/test_session_routes.py | 436 ++++++- tests/session/test_auto_model_failover.py | 1005 +++++++++++++++++ .../session/test_message_parts_persistence.py | 56 + tui/flocks/config/config.ts | 11 + webui/src/api/provider.ts | 11 + webui/src/api/session.ts | 10 +- .../src/components/common/SessionChat.test.ts | 34 + webui/src/components/common/SessionChat.tsx | 13 + .../features/session-chat/sseActions.test.ts | 19 + webui/src/features/session-chat/sseActions.ts | 8 + .../features/session-chat/sseRouting.test.ts | 4 + webui/src/hooks/useChatModelResources.ts | 24 +- webui/src/hooks/useSessions.test.ts | 16 + webui/src/hooks/useSessions.ts | 3 + webui/src/locales/en-US/model.json | 21 + webui/src/locales/en-US/session.json | 3 + webui/src/locales/zh-CN/model.json | 21 + webui/src/locales/zh-CN/session.json | 3 + webui/src/pages/Model/index.test.tsx | 170 +++ webui/src/pages/Model/index.tsx | 383 ++++++- webui/src/pages/Session/index.test.tsx | 268 ++++- webui/src/pages/Session/index.tsx | 181 ++- webui/src/types/index.ts | 11 + 37 files changed, 4670 insertions(+), 178 deletions(-) create mode 100644 tests/server/routes/test_default_model_fallbacks.py create mode 100644 tests/session/test_auto_model_failover.py diff --git a/flocks/config/config.py b/flocks/config/config.py index 65d151dba..7defe4c4e 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -628,6 +628,15 @@ def get_extra(self, key: str, default: Any = None) -> Any: # ==================== Main Configuration ==================== +class FallbackProviderConfig(BaseModel): + """Ordered model identity used for runtime provider fallback.""" + + model_config = {"extra": "forbid"} + + provider_id: str + model_id: str + + class ConfigInfo(BaseModel): """ Main configuration schema @@ -660,6 +669,7 @@ class ConfigInfo(BaseModel): enabled_providers: Optional[List[str]] = None model: Optional[str] = None small_model: Optional[str] = Field(None, alias="smallModel") + fallback_providers: Optional[List[FallbackProviderConfig]] = None default_agent: Optional[str] = Field(None, alias="defaultAgent") username: Optional[str] = None mode: Optional[Dict[str, AgentConfig]] = Field(None, description="@deprecated Use 'agent'") @@ -711,6 +721,72 @@ class ConfigInfo(BaseModel): "workspace_access (none/ro/rw), workspace_root, docker, tools, prune." ), ) + + @field_validator("fallback_providers", mode="before") + @classmethod + def normalize_fallback_providers(cls, value: Any) -> Any: + """Keep config loading tolerant of malformed fallback entries. + + Runtime and API readers still receive typed, trimmed, ordered entries. + Invalid identities are ignored and duplicate identities retain their + first position, matching :class:`ConfigWriter` raw-read behavior. + """ + if value is None: + return None + + from flocks.utils.log import Log + + config_log = Log.create(service="config") + if not isinstance(value, list): + config_log.warning("config.fallback_providers_invalid", { + "reason": "not_a_list", + }) + return [] + + normalized: List[Dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for index, raw in enumerate(value): + if not isinstance(raw, dict): + config_log.warning("config.fallback_provider_invalid", { + "index": index, + "reason": "not_an_object", + }) + continue + + provider_id = raw.get("provider_id") + model_id = raw.get("model_id") + if not isinstance(provider_id, str) or not isinstance(model_id, str): + config_log.warning("config.fallback_provider_invalid", { + "index": index, + "reason": "invalid_identity", + }) + continue + + provider_id = provider_id.strip() + model_id = model_id.strip() + if not provider_id or not model_id: + config_log.warning("config.fallback_provider_invalid", { + "index": index, + "reason": "empty_identity", + }) + continue + + identity = (provider_id, model_id) + if identity in seen: + config_log.warning("config.fallback_provider_duplicate", { + "index": index, + "provider_id": provider_id, + "model_id": model_id, + }) + continue + + seen.add(identity) + normalized.append({ + "provider_id": provider_id, + "model_id": model_id, + }) + + return normalized allow_read_paths: Optional[List[str]] = Field( None, alias="allowReadPaths", diff --git a/flocks/config/config_writer.py b/flocks/config/config_writer.py index 7251c6d17..a7eaf8da1 100644 --- a/flocks/config/config_writer.py +++ b/flocks/config/config_writer.py @@ -400,6 +400,98 @@ def get_all_default_models(cls) -> Dict[str, Dict[str, Any]]: data = cls._read_raw() return data.get("default_models", {}) + # ------------------------------------------------------------------ + # Runtime model fallbacks (fallback_providers section) + # ------------------------------------------------------------------ + + @classmethod + def get_fallback_providers(cls) -> List[Dict[str, str]]: + """Return the ordered, structurally valid runtime fallback models. + + Malformed entries are ignored without rewriting the user's config. + Duplicate identities retain their first position. Model availability is + intentionally not checked here so stale references remain visible to + configuration clients and can be repaired. + """ + data = cls._read_raw() + raw_fallbacks = data.get("fallback_providers", []) + if not isinstance(raw_fallbacks, list): + log.warning("config_writer.fallback_providers_invalid", { + "reason": "not_a_list", + }) + return [] + + fallbacks: List[Dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for index, raw in enumerate(raw_fallbacks): + if not isinstance(raw, dict): + log.warning("config_writer.fallback_provider_invalid", { + "index": index, + "reason": "not_an_object", + }) + continue + + provider_id = raw.get("provider_id") + model_id = raw.get("model_id") + if not isinstance(provider_id, str) or not isinstance(model_id, str): + log.warning("config_writer.fallback_provider_invalid", { + "index": index, + "reason": "invalid_identity", + }) + continue + + provider_id = provider_id.strip() + model_id = model_id.strip() + if not provider_id or not model_id: + log.warning("config_writer.fallback_provider_invalid", { + "index": index, + "reason": "empty_identity", + }) + continue + + identity = (provider_id, model_id) + if identity in seen: + log.warning("config_writer.fallback_provider_duplicate", { + "index": index, + "provider_id": provider_id, + "model_id": model_id, + }) + continue + + seen.add(identity) + fallbacks.append({ + "provider_id": provider_id, + "model_id": model_id, + }) + + return fallbacks + + @classmethod + def set_fallback_providers( + cls, + fallbacks: List[Dict[str, str]], + ) -> None: + """Atomically replace the ordered runtime fallback model list. + + An empty list removes the top-level key instead of persisting redundant + empty configuration. Callers are responsible for validating identities. + """ + data = cls._read_raw() + if fallbacks: + data["fallback_providers"] = [ + { + "provider_id": fallback["provider_id"], + "model_id": fallback["model_id"], + } + for fallback in fallbacks + ] + else: + data.pop("fallback_providers", None) + cls._write_raw(data) + log.info("config_writer.fallback_providers_set", { + "count": len(fallbacks), + }) + # ------------------------------------------------------------------ # MCP server CRUD (mcp section) # ------------------------------------------------------------------ diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index 89c815c71..50d98cae0 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -737,66 +737,73 @@ async def apply_config(cls, config: Optional[Any] = None, provider_id: Optional[ if not provider: continue + # Provider credentials/options are optional. Model definitions and + # display names below still need to load when credentials are + # unresolved or intentionally omitted (for example, while editing + # an Auto fallback before connecting that provider). options = getattr(pconfig, "options", None) - if not options: - continue - + options_data: Optional[Dict[str, Any]] = None if hasattr(options, "model_dump"): - options_data = options.model_dump(exclude_none=True, by_alias=False) + options_data = options.model_dump( + exclude_none=True, + by_alias=False, + ) elif isinstance(options, dict): - options_data = {k: v for k, v in options.items() if v is not None} - else: - continue - - # Handle both Python-style (api_key, base_url) and JS-style (apiKey, baseURL) - api_key = ( - options_data.pop("api_key", None) - or options_data.pop("apiKey", None) - ) - base_url = ( - options_data.pop("base_url", None) - or options_data.pop("baseURL", None) - ) - - # Treat empty strings as None (e.g. unresolved {secret:xxx}) - if isinstance(api_key, str) and not api_key.strip(): - api_key = None - if isinstance(base_url, str) and not base_url.strip(): - base_url = None - - # Also filter out remaining options that resolved to empty strings - options_data = { - k: v for k, v in options_data.items() - if not (isinstance(v, str) and not v.strip()) - } - - if api_key is None and base_url is None and not options_data: - continue + options_data = { + key: value + for key, value in options.items() + if value is not None + } + + if options_data is not None: + # Handle both Python-style (api_key, base_url) and JS-style + # (apiKey, baseURL). + api_key = ( + options_data.pop("api_key", None) + or options_data.pop("apiKey", None) + ) + base_url = ( + options_data.pop("base_url", None) + or options_data.pop("baseURL", None) + ) - # ----- Idempotent ProviderConfig update ------------------------------- - # ``apply_config`` is called from many hot paths: every session - # step (``session.runner._step``), every workflow ``llm.ask``, - # the ``/session/*`` HTTP routes, plus startup. When session and - # workflow run concurrently on different event loops they would - # otherwise rewrite the same ``provider._config`` repeatedly and - # race on the ``_config_models`` rebuild. Skip mutation whenever - # the desired config already matches. - desired_cfg = ProviderConfig( - provider_id=pid, - api_key=api_key, - base_url=base_url, - custom_settings=options_data, - ) - current_cfg = provider._config - current_unchanged = ( - current_cfg is not None - and getattr(current_cfg, "api_key", None) == desired_cfg.api_key - and getattr(current_cfg, "base_url", None) == desired_cfg.base_url - and (getattr(current_cfg, "custom_settings", None) or {}) - == (desired_cfg.custom_settings or {}) - ) - if not current_unchanged: - provider.configure(desired_cfg) + # Treat empty strings as None (e.g. unresolved {secret:xxx}). + if isinstance(api_key, str) and not api_key.strip(): + api_key = None + if isinstance(base_url, str) and not base_url.strip(): + base_url = None + + # Also filter out remaining options that resolved to empty strings. + options_data = { + key: value + for key, value in options_data.items() + if not (isinstance(value, str) and not value.strip()) + } + + if api_key is not None or base_url is not None or options_data: + # ----- Idempotent ProviderConfig update ------------------- + # ``apply_config`` is called from many hot paths: every + # session step, every workflow ``llm.ask``, HTTP routes, + # and startup. Skip mutation whenever the desired config + # already matches. + desired_cfg = ProviderConfig( + provider_id=pid, + api_key=api_key, + base_url=base_url, + custom_settings=options_data, + ) + current_cfg = provider._config + current_unchanged = ( + current_cfg is not None + and getattr(current_cfg, "api_key", None) + == desired_cfg.api_key + and getattr(current_cfg, "base_url", None) + == desired_cfg.base_url + and (getattr(current_cfg, "custom_settings", None) or {}) + == (desired_cfg.custom_settings or {}) + ) + if not current_unchanged: + provider.configure(desired_cfg) # Update provider display name from flocks.json only for providers # that support custom naming (openai-compatible instances and custom-* providers). diff --git a/flocks/server/routes/default_model.py b/flocks/server/routes/default_model.py index cda00f077..2a4b86de7 100644 --- a/flocks/server/routes/default_model.py +++ b/flocks/server/routes/default_model.py @@ -4,12 +4,15 @@ Provides endpoints to get/set default models per model type. """ -from typing import List, Optional +from typing import Dict, List from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field +from flocks.config.config import Config, FallbackProviderConfig +from flocks.config.config_writer import ConfigWriter from flocks.provider.model_manager import get_model_manager +from flocks.provider.provider import Provider from flocks.provider.types import DefaultModelConfig, ModelType from flocks.utils.log import Log @@ -31,6 +34,12 @@ class DefaultModelListResponse(BaseModel): defaults: List[DefaultModelConfig] +class FallbackProvidersConfig(BaseModel): + """Ordered runtime fallback model configuration.""" + + fallback_providers: List[FallbackProviderConfig] = Field(default_factory=list) + + # ==================== Routes ==================== @@ -57,7 +66,6 @@ async def get_all_defaults() -> DefaultModelListResponse: ) async def get_resolved_default_model(): """Return the resolved default LLM model (provider_id + model_id).""" - from flocks.config.config import Config result = await Config.resolve_default_llm() if not result: raise HTTPException( @@ -67,6 +75,110 @@ async def get_resolved_default_model(): return {"provider_id": result["provider_id"], "model_id": result["model_id"]} +@router.get( + "/fallbacks", + response_model=FallbackProvidersConfig, + summary="Get runtime fallback models", + description="Get the ordered fallback model configuration for WebUI Auto mode", +) +async def get_fallback_providers() -> FallbackProvidersConfig: + """Return the ordered, structurally valid fallback model list.""" + return FallbackProvidersConfig( + fallback_providers=ConfigWriter.get_fallback_providers() + ) + + +@router.put( + "/fallbacks", + response_model=FallbackProvidersConfig, + summary="Replace runtime fallback models", + description="Atomically replace the ordered fallback model configuration", +) +async def set_fallback_providers( + body: FallbackProvidersConfig, +) -> FallbackProvidersConfig: + """Validate and atomically replace the runtime fallback model list.""" + config = await Config.get() + await Provider.apply_config(config) + manager = get_model_manager() + primary = await Config.resolve_default_llm() + primary_identity = None + if primary: + primary_identity = ( + primary["provider_id"].strip(), + primary["model_id"].strip(), + ) + disabled_providers = set( + getattr(config, "disabled_providers", None) or [] + ) + enabled_providers = getattr(config, "enabled_providers", None) + + normalized: List[Dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for index, fallback in enumerate(body.fallback_providers): + provider_id = fallback.provider_id.strip() + model_id = fallback.model_id.strip() + if not provider_id or not model_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fallback at index {index} must include provider_id and model_id", + ) + + identity = (provider_id, model_id) + if identity in seen: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Duplicate fallback model '{provider_id}/{model_id}' " + f"at index {index}" + ), + ) + if identity == primary_identity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Fallback model '{provider_id}/{model_id}' is the current " + "default LLM" + ), + ) + + if provider_id in disabled_providers or ( + enabled_providers and provider_id not in enabled_providers + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fallback provider '{provider_id}' is disabled", + ) + + definition = manager.get_model(provider_id, model_id) + if definition is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown fallback model '{provider_id}/{model_id}'", + ) + if definition.model_type != ModelType.LLM: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fallback model '{provider_id}/{model_id}' is not an LLM", + ) + + setting = manager.get_setting(provider_id, model_id) + if setting is not None and not setting.enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Fallback model '{provider_id}/{model_id}' is disabled", + ) + + seen.add(identity) + normalized.append({ + "provider_id": provider_id, + "model_id": model_id, + }) + + ConfigWriter.set_fallback_providers(normalized) + return FallbackProvidersConfig(fallback_providers=normalized) + + @router.get( "/{model_type}", response_model=DefaultModelConfig, diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index ee332a65a..664bba09e 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -191,6 +191,10 @@ class SessionCreateRequest(BaseModel): title: Optional[str] = Field(None, description="Session title") permission: Optional[List[PermissionRule]] = Field(None, description="Permission rules") category: Optional[str] = Field(None, description="Session category (e.g. 'user', 'workflow')") + model_auto: bool = Field( + False, + description="Enable WebUI runtime model failover for this session", + ) class FileDiff(BaseModel): @@ -247,6 +251,7 @@ class SessionResponse(BaseModel): provider: Optional[str] = Field(None, description="Pinned provider ID") model: Optional[str] = Field(None, description="Pinned model ID") model_pinned: bool = Field(False, description="Whether provider/model are pinned for this session") + model_auto: bool = Field(False, description="Whether WebUI Auto mode is selected") ownerUserID: Optional[str] = Field(None, description="Session owner user id") ownerUsername: Optional[str] = Field(None, description="Session owner username") canWrite: bool = Field(False, description="Whether current user can continue this session") @@ -270,6 +275,7 @@ class SessionListItem(BaseModel): provider: Optional[str] = None model: Optional[str] = None model_pinned: bool = False + model_auto: bool = False canWrite: bool = False canDelete: bool = False isShared: bool = False @@ -317,6 +323,7 @@ def _session_to_response( provider=session.provider, model=session.model, model_pinned=session.model_pinned, + model_auto=session.model_auto, ownerUserID=session.owner_user_id, ownerUsername=session.owner_username, canWrite=can_write, @@ -356,6 +363,7 @@ def _session_to_list_item( provider=session.provider, model=session.model, model_pinned=session.model_pinned, + model_auto=session.model_auto, canWrite=SessionPolicy.can_write(session, current_user), canDelete=SessionPolicy.can_delete(session, current_user), isShared=SessionPolicy.is_shared(session, shared_project_ids), @@ -705,6 +713,25 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR await assert_license_active(feature="session_create") if request is None: request = SessionCreateRequest() + if request.model_auto: + if request.category not in (None, "user"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Auto mode is only available for user sessions", + ) + if current_user.id == API_TOKEN_SERVICE_USER_ID: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Auto mode can only be enabled from the WebUI", + ) + from flocks.session.session_loop import SessionLoop + + auto_available, auto_reason = await SessionLoop.validate_auto_configuration() + if not auto_available: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Auto mode is unavailable: {auto_reason}", + ) from flocks.project.project import DEFAULT_PROJECT_ID, Project @@ -778,6 +805,8 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR permission=permission, owner_user_id=None if is_api_token_client else current_user.id, owner_username=None if is_api_token_client else current_user.username, + model_auto=request.model_auto, + model_pinned=False, **({"category": request.category} if request.category else {}), ) Project.invalidate_session_stats() @@ -1068,6 +1097,7 @@ class SessionUpdateRequest(BaseModel): provider: Optional[str] = Field(None, description="Pinned provider ID") model: Optional[str] = Field(None, description="Pinned model ID") model_pinned: Optional[bool] = Field(None, description="Whether provider/model are pinned for this session") + model_auto: Optional[bool] = Field(None, description="Whether WebUI Auto mode is selected") @router.patch( @@ -1092,6 +1122,40 @@ async def update_session( current_user = require_user(http_request) _require_session_write_access(existing, current_user) + if request.model_auto is True and ( + request.provider is not None + or request.model is not None + or request.model_pinned is True + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Auto mode cannot be combined with a pinned provider/model", + ) + if request.model_auto is True: + if existing.category != "user": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Auto mode is only available for user sessions", + ) + if current_user.id == API_TOKEN_SERVICE_USER_ID: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Auto mode can only be enabled from the WebUI", + ) + from flocks.session.session_loop import SessionLoop + + auto_available, auto_reason = await SessionLoop.validate_auto_configuration() + if not auto_available: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Auto mode is unavailable: {auto_reason}", + ) + if (request.provider is None) != (request.model is None): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="provider and model must be updated together", + ) + updates = {} if request.title is not None: updates["title"] = request.title @@ -1103,6 +1167,15 @@ async def update_session( updates["model"] = request.model if request.model_pinned is not None: updates["model_pinned"] = request.model_pinned + if request.model_pinned: + updates["model_auto"] = False + if request.model_auto is not None: + updates["model_auto"] = request.model_auto + if request.model_auto: + updates["model_pinned"] = False + if request.provider is not None and request.model is not None: + updates["model_auto"] = False + updates["model_pinned"] = True session = await Session.update( project_id=existing.project_id, @@ -1115,6 +1188,15 @@ async def update_session( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) + + if ( + request.model_auto is False + or request.model_pinned is True + or (request.provider is not None and request.model is not None) + ): + from flocks.session.session_loop import SessionLoop + + SessionLoop.clear_auto_failover_state(sessionID) log.info("session.updated", {"session_id": sessionID}) return await _session_to_response_with_goal(session) @@ -2119,7 +2201,7 @@ async def _guarded_coro() -> None: async def _prepare_replay_runtime( session_id: str, user_message, -) -> Dict[str, str]: +) -> Dict[str, Any]: """Resolve replay runtime state before mutating session history.""" from flocks.agent.registry import Agent from flocks.config.config import Config @@ -2127,21 +2209,40 @@ async def _prepare_replay_runtime( agent_name = getattr(user_message, "agent", None) or await Agent.default_agent() agent = await Agent.get(agent_name) or await Agent.get(DEFAULT_AGENT) - # Replay should follow the model that is active *now* for this session - # (current session pin / current default / current agent override), not the - # historical model stored on the original user message being replayed. - dummy_request = type( - "_MessageReplayRequest", - (), - {"model": None, "agent": agent_name}, - )() - provider_id, model_id, _ = await _resolve_model(dummy_request, agent, session_id) + session = await Session.get_by_id(session_id) + auto_failover = bool( + session + and getattr(session, "category", "user") == "user" + and getattr(session, "model_auto", False) + ) + if auto_failover: + default_llm = await Config.resolve_default_llm() + if not default_llm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Auto mode requires a configured default LLM", + ) + provider_id = default_llm["provider_id"] + model_id = default_llm["model_id"] + else: + # Replay follows the model active *now*, not the model stored on the + # historical user message. + dummy_request = type( + "_MessageReplayRequest", + (), + {"model": None, "agent": agent_name}, + )() + provider_id, model_id, _ = await _resolve_model( + dummy_request, + agent, + session_id, + ) Provider._ensure_initialized() config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) provider = Provider.get(provider_id) - if not provider: + if not provider and not auto_failover: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Provider {provider_id} not found", @@ -2151,6 +2252,7 @@ async def _prepare_replay_runtime( "agent_name": agent_name, "provider_id": provider_id, "model_id": model_id, + "auto_failover": auto_failover, } @@ -2159,7 +2261,7 @@ async def _run_existing_user_message( session, user_message, working_directory: str, - runtime: Optional[Dict[str, str]] = None, + runtime: Optional[Dict[str, Any]] = None, ): """Run SessionLoop using an already-persisted user message.""" from flocks.server.routes.event import publish_event @@ -2192,6 +2294,7 @@ async def _on_error(error: str): agent_name=agent_name, callbacks=loop_callbacks, working_directory=working_directory, + auto_failover=bool(runtime.get("auto_failover", False)), ) if result.action == "queued": @@ -2211,6 +2314,8 @@ async def _on_error(error: str): assistant_message_id = None created_ms = end_ms final_tokens = {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}} + actual_provider_id = result.provider_id or provider_id + actual_model_id = result.model_id or model_id if result.last_message: assistant_message_id = result.last_message.id @@ -2219,6 +2324,14 @@ async def _on_error(error: str): finish = getattr(result.last_message, "finish", None) if finish: finish_reason = finish + actual_provider_id = ( + getattr(result.last_message, "providerID", None) + or actual_provider_id + ) + actual_model_id = ( + getattr(result.last_message, "modelID", None) + or actual_model_id + ) result_time = getattr(result.last_message, "time", None) if isinstance(result_time, dict): created_ms = result_time.get("created", created_ms) @@ -2238,8 +2351,8 @@ async def _on_error(error: str): "role": "assistant", "time": {"created": created_ms, "completed": end_ms}, "parentID": user_message.id, - "modelID": model_id, - "providerID": provider_id, + "modelID": actual_model_id, + "providerID": actual_provider_id, "mode": agent_name, "agent": agent_name, "path": {"cwd": working_directory, "root": working_directory}, @@ -2252,8 +2365,8 @@ async def _on_error(error: str): publish_event, session_id, session=session, - provider_id=provider_id, - model_id=model_id, + provider_id=actual_provider_id, + model_id=actual_model_id, ) log.info("session.message.replay.completed", { @@ -2851,9 +2964,27 @@ async def _process_session_message( agent_name = request.agent or await Agent.default_agent() agent = await Agent.get(agent_name) or await Agent.get(DEFAULT_AGENT) - provider_id, model_id, model_source = await _resolve_model( - request, agent, sessionID + auto_failover = bool( + getattr(session, "category", "user") == "user" + and getattr(session, "model_auto", False) + and not request.model ) + if auto_failover: + from flocks.config.config import Config + + default_llm = await Config.resolve_default_llm() + if not default_llm: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Auto mode requires a configured default LLM", + ) + provider_id = default_llm["provider_id"] + model_id = default_llm["model_id"] + model_source = "auto_primary" + else: + provider_id, model_id, model_source = await _resolve_model( + request, agent, sessionID + ) log.info("session.message.model", { "provider_id": provider_id, @@ -2873,15 +3004,17 @@ async def _process_session_message( session.provider = provider_id session.model = model_id session.model_pinned = True + session.model_auto = False + SessionLoop.clear_auto_failover_state(sessionID) # Ensure providers are initialized and configured Provider._ensure_initialized() from flocks.config.config import Config config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) - + provider = Provider.get(provider_id) - if not provider: + if not provider and not auto_failover: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Provider {provider_id} not found" @@ -3080,6 +3213,7 @@ async def _on_error(error: str): agent_name=agent_name, callbacks=loop_callbacks, working_directory=working_directory, + auto_failover=auto_failover, ) # ------------------------------------------------------------------ @@ -3109,6 +3243,8 @@ async def _on_error(error: str): final_content = "" assistant_message_id = None final_tokens = {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}} + actual_provider_id = result.provider_id or provider_id + actual_model_id = result.model_id or model_id if result.last_message: assistant_message_id = result.last_message.id @@ -3117,6 +3253,14 @@ async def _on_error(error: str): finish = getattr(result.last_message, 'finish', None) if finish: finish_reason = finish + actual_provider_id = ( + getattr(result.last_message, "providerID", None) + or actual_provider_id + ) + actual_model_id = ( + getattr(result.last_message, "modelID", None) + or actual_model_id + ) if result.action == "error": finish_reason = "error" @@ -3134,8 +3278,8 @@ async def _on_error(error: str): "role": "assistant", "time": {"created": now_ms, "completed": end_ms}, "parentID": user_message_id, - "modelID": model_id, - "providerID": provider_id, + "modelID": actual_model_id, + "providerID": actual_provider_id, "mode": agent_name, "agent": agent_name, "path": {"cwd": working_directory, "root": working_directory}, @@ -3148,8 +3292,8 @@ async def _on_error(error: str): publish_event, sessionID, session=session, - provider_id=provider_id, - model_id=model_id, + provider_id=actual_provider_id, + model_id=actual_model_id, ) # Collect parts for the response @@ -3177,8 +3321,8 @@ async def _on_error(error: str): title_task = loop.create_task( SessionTitle.generate_title_after_first_message( session_id=sessionID, - model_id=model_id, - provider_id=provider_id, + model_id=actual_model_id, + provider_id=actual_provider_id, event_publish_callback=publish_event, ) ) @@ -3196,8 +3340,8 @@ async def _on_error(error: str): "role": "assistant", "time": {"created": now_ms, "completed": end_ms}, "parentID": user_message_id, - "modelID": model_id, - "providerID": provider_id, + "modelID": actual_model_id, + "providerID": actual_provider_id, "mode": agent_name, "agent": agent_name, "path": {"cwd": working_directory, "root": working_directory}, diff --git a/flocks/session/message.py b/flocks/session/message.py index 7fd6dcbe2..da4e5ae12 100644 --- a/flocks/session/message.py +++ b/flocks/session/message.py @@ -1863,19 +1863,73 @@ async def delete(cls, session_id: str, message_id: str) -> bool: return False messages = cls._messages_cache.get(session_id, []) if idx < len(messages) and messages[idx].id == message_id: - messages.pop(idx) + missing = object() + removed_message = messages.pop(idx) cls._rebuild_id_index(session_id) - if session_id in cls._parts_cache: - cls._parts_cache[session_id].pop(message_id, None) - cls._parts_revision_cache.get(session_id, {}).pop(message_id, None) - cls._parts_serialized_cache.get(session_id, {}).pop(message_id, None) + parts_cache = cls._parts_cache.get(session_id) + removed_parts = ( + parts_cache.pop(message_id, missing) + if parts_cache is not None + else missing + ) + parts_revision_cache = cls._parts_revision_cache.get(session_id) + removed_revision = ( + parts_revision_cache.pop(message_id, missing) + if parts_revision_cache is not None + else missing + ) + parts_serialized_cache = cls._parts_serialized_cache.get(session_id) + removed_serialized = ( + parts_serialized_cache.pop(message_id, missing) + if parts_serialized_cache is not None + else missing + ) + had_pending_parts_flush = session_id in cls._parts_flush_tasks cls._cancel_parts_flush_task(session_id) - await cls._persist_messages(session_id) - if cls._parts_storage_format.get(session_id) == "legacy": - await cls._persist_parts(session_id) - else: - await Storage.delete(cls._parts_item_key(session_id, message_id)) - cls._parts_persisted_mids.setdefault(session_id, set()).discard(message_id) + try: + await cls._persist_messages(session_id) + except BaseException: + # Message metadata is the deletion commit point. Restore + # every in-memory index/cache if it was not persisted so a + # later retry or process restart sees the same message. + messages.insert(idx, removed_message) + cls._rebuild_id_index(session_id) + if parts_cache is not None and removed_parts is not missing: + parts_cache[message_id] = removed_parts + if ( + parts_revision_cache is not None + and removed_revision is not missing + ): + parts_revision_cache[message_id] = removed_revision + if ( + parts_serialized_cache is not None + and removed_serialized is not missing + ): + parts_serialized_cache[message_id] = removed_serialized + if had_pending_parts_flush: + cls._schedule_parts_flush( + session_id, + message_id=message_id, + ) + raise + + try: + if cls._parts_storage_format.get(session_id) == "legacy": + await cls._persist_parts(session_id) + else: + await Storage.delete(cls._parts_item_key(session_id, message_id)) + cls._parts_persisted_mids.setdefault(session_id, set()).discard( + message_id + ) + except Exception as exc: + # Metadata deletion has committed. Orphaned parts are not + # user-visible and can be cleaned later; restoring the + # message here would make cache and durable metadata diverge. + log.warn("message.delete.parts_cleanup_failed", { + "session_id": session_id, + "message_id": message_id, + "error": str(exc), + }) log.info("message.deleted", {"id": message_id, "session_id": session_id}) return True return False diff --git a/flocks/session/runner.py b/flocks/session/runner.py index 7ff7d86b9..67266afc8 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -204,6 +204,43 @@ class ToolCall: name: str arguments: Dict[str, Any] + +@dataclass +class LlmAttemptState: + """Observable side effects accumulated across retries for one model.""" + + received_chunk: bool = False + observable_output_started: bool = False + tool_execution_started: bool = False + + @property + def replay_safe(self) -> bool: + """Whether the same logical LLM call can safely run on another model.""" + return not self.observable_output_started and not self.tool_execution_started + + +@dataclass(frozen=True) +class FailoverDecision: + """Hermes-aligned retry/failover classification for a provider error.""" + + eligible: bool + reason: str + same_model_retries: int = 3 + + +@dataclass +class StepFailure: + """Failure details returned to SessionLoop when finalization is deferred.""" + + message: str + error_data: Dict[str, Any] + assistant_message_id: Optional[str] + reason: str + allow_fallback: bool + attempt_state: LlmAttemptState + attempts: int = 0 + + @dataclass class StepResult: """Result of a single processing step.""" @@ -212,6 +249,7 @@ class StepResult: tool_calls: List[ToolCall] = field(default_factory=list) error: Optional[str] = None usage: Optional[Dict[str, int]] = None + failure: Optional[StepFailure] = None @dataclass @@ -257,6 +295,8 @@ def __init__( session_ctx: Optional[Any] = None, # SessionContext interface memory_bootstrap_data: Optional[Dict[str, Any]] = None, static_cache: Optional[Dict[str, Any]] = None, + defer_step_errors: bool = False, + failover_available: bool = False, ): self.session = session from flocks.session.core.defaults import fallback_provider_id, fallback_model_id @@ -271,6 +311,9 @@ def __init__( self.session_ctx = session_ctx # SessionContext interface for decoupled access self._memory_bootstrap_data: Optional[Dict[str, Any]] = memory_bootstrap_data self._static_cache = static_cache if static_cache is not None else {} + self._defer_step_errors = defer_step_errors + self._failover_available = failover_available + self._attempt_state = LlmAttemptState() @staticmethod def _canonical_tool_signature(tool_name: str, arguments: Dict[str, Any]) -> str: @@ -960,6 +1003,121 @@ def is_aborted(self) -> bool: if self._external_abort is not None and self._external_abort.is_set(): return True return False + + @staticmethod + def classify_failover_error(error: Dict[str, Any]) -> FailoverDecision: + """Classify a provider failure using Hermes-compatible switch timing. + + The classifier deliberately requires an API-shaped error (status code, + APIError marker, or a known provider response pattern). Local Python, + storage, hook, and tool failures must never move a user turn to another + model. + """ + data = error.get("data") or {} + status_code = data.get("statusCode") + try: + status_code = int(status_code) if status_code is not None else None + except (TypeError, ValueError): + status_code = None + + message = str(data.get("message") or error.get("message") or "") + lowered = message.lower() + error_name = str(error.get("name") or "") + + if status_code == 413 or any(pattern in lowered for pattern in ( + "context length", "context_length", "context window", "prompt is too long", + "request entity too large", "payload too large", + )): + return FailoverDecision(False, "context_overflow") + if error_name in {"CancelledError", "MessageAbortedError", "AbortedError"}: + return FailoverDecision(False, "cancelled") + + quota_or_rate_limited = any(pattern in lowered for pattern in ( + "rate limit", "too many requests", "quota exceeded", "resource exhausted", + "insufficient quota", "billing limit", + )) + # Some providers report exhausted quota as HTTP 401/403 rather than + # 429. Classify the semantic error before the generic auth branch so + # the primary receives the same cooldown as other quota failures. + if status_code == 429 or quota_or_rate_limited: + reason = "billing" if any( + pattern in lowered for pattern in ("billing", "insufficient quota") + ) else "rate_limit" + return FailoverDecision(True, reason, 0) + if status_code in {401, 403}: + return FailoverDecision(True, "auth", 0) + if status_code == 402: + return FailoverDecision(True, "billing", 0) + + if "model" in lowered and any(pattern in lowered for pattern in ( + "not found", "model_not_found", "unknown model", "no such model", + )): + return FailoverDecision(True, "model_not_found", 0) + + if status_code == 404: + if any(pattern in lowered for pattern in ( + "model not found", "model_not_found", "unknown model", "no such model", + )): + return FailoverDecision(True, "model_not_found", 0) + return FailoverDecision(True, "unknown_api", 3) + + if status_code in {408, 504} or data.get("isConnectionError") is True or any( + pattern in lowered + for pattern in ("timeout", "timed out", "connection error", "connection reset") + ): + return FailoverDecision(True, "timeout", 1) + if status_code in {503, 529} or any( + pattern in lowered for pattern in ("overloaded", "temporarily unavailable") + ): + return FailoverDecision(True, "overloaded", 1) + if status_code in {500, 502}: + return FailoverDecision(True, "server_error", 3) + + if any(pattern in lowered for pattern in ( + "content policy", "content filter", "safety policy", "policy violation", + )): + return FailoverDecision(True, "content_policy", 0) + if error_name == "JSONDecodeError" or any( + pattern in lowered for pattern in ( + "malformed response", "invalid response", "empty choices", + "returned choice with null", "null message", + ) + ): + return FailoverDecision(True, "invalid_response", 0) + + if status_code is not None and 400 <= status_code < 500: + return FailoverDecision(True, "provider_request", 0) + if error_name == "APIError" or data.get("isRetryable") is True: + return FailoverDecision(True, "unknown_api", 3) + return FailoverDecision(False, "local_error") + + def _deferred_failure_result( + self, + *, + message: str, + error_data: Dict[str, Any], + assistant_message_id: Optional[str], + decision: FailoverDecision, + attempts: int, + ) -> StepResult: + state = LlmAttemptState( + received_chunk=self._attempt_state.received_chunk, + observable_output_started=self._attempt_state.observable_output_started, + tool_execution_started=self._attempt_state.tool_execution_started, + ) + return StepResult( + action="stop", + error=message, + failure=StepFailure( + message=message, + error_data=error_data, + assistant_message_id=assistant_message_id, + reason=decision.reason, + allow_fallback=decision.eligible and state.replay_safe, + attempt_state=state, + attempts=attempts, + ), + ) async def _process_step( self, @@ -967,6 +1125,7 @@ async def _process_step( last_user: MessageInfo, ) -> StepResult: """Process a single step in the loop with retry logic.""" + self._attempt_state = LlmAttemptState() # Check for CLI callbacks (if running in CLI mode) # Only use CLI fallback if no callbacks were explicitly provided via constructor has_explicit_callbacks = any([ @@ -1004,6 +1163,18 @@ async def _process_step( provider = Provider.get(self.provider_id) if not provider: error = f"Provider {self.provider_id} not found" + if self._defer_step_errors: + return self._deferred_failure_result( + message=error, + error_data={ + "name": "ProviderUnavailableError", + "message": error, + "data": {"message": error}, + }, + assistant_message_id=None, + decision=FailoverDecision(True, "provider_unavailable", 0), + attempts=0, + ) if self.callbacks.on_error: await self.callbacks.on_error(error) return StepResult(action="stop", error=error) @@ -1019,6 +1190,18 @@ async def _process_step( if not provider.is_configured(): error = f"Provider {self.provider_id} not configured" + if self._defer_step_errors: + return self._deferred_failure_result( + message=error, + error_data={ + "name": "ProviderUnavailableError", + "message": error, + "data": {"message": error}, + }, + assistant_message_id=None, + decision=FailoverDecision(True, "provider_unavailable", 0), + attempts=0, + ) if self.callbacks.on_error: await self.callbacks.on_error(error) return StepResult(action="stop", error=error) @@ -1270,7 +1453,11 @@ async def device_asset_prompt_factory() -> Optional[str]: if (result.action == "stop" and not result.error and not result.content and not result.tool_calls): empty_attempt += 1 - if empty_attempt <= MAX_EMPTY_RETRIES: + unsafe_auto_replay = ( + self._defer_step_errors + and not self._attempt_state.replay_safe + ) + if empty_attempt <= MAX_EMPTY_RETRIES and not unsafe_auto_replay: # Record usage for this empty attempt even though we are # about to retry – the provider may have already charged # for the tokens returned in this response. @@ -1297,10 +1484,16 @@ async def device_asset_prompt_factory() -> Optional[str]: # All retries exhausted — surface a clear error so the # user knows the model is incompatible, rather than # silently hanging or showing a blank response. - empty_error_msg = ( - f"Model '{self.model_id}' returned an empty response " - f"after {MAX_EMPTY_RETRIES} retries." - ) + if unsafe_auto_replay: + empty_error_msg = ( + f"Model '{self.model_id}' returned no final content after " + "starting observable output; the call was not replayed." + ) + else: + empty_error_msg = ( + f"Model '{self.model_id}' returned an empty response " + f"after {MAX_EMPTY_RETRIES} retries." + ) log.error("runner.step.empty_response_exhausted", { "session_id": self.session.id, "model": self.model_id, @@ -1315,6 +1508,14 @@ async def device_asset_prompt_factory() -> Optional[str]: "attempts": empty_attempt, }, } + if self._defer_step_errors: + return self._deferred_failure_result( + message=empty_error_msg, + error_data=empty_error_dict, + assistant_message_id=assistant_msg.id, + decision=FailoverDecision(True, "empty_response", 3), + attempts=empty_attempt, + ) if self.callbacks.on_error: await self.callbacks.on_error(empty_error_msg) await Message.update( @@ -1374,7 +1575,25 @@ async def device_asset_prompt_factory() -> Optional[str]: # Check if retryable retry_message = SessionRetry.retryable(error_dict) - will_retry = retry_message is not None and error_attempt <= MAX_ERROR_RETRIES + failover_decision = self.classify_failover_error(error_dict) + retry_limit = MAX_ERROR_RETRIES + if ( + self._defer_step_errors + and self._failover_available + and failover_decision.eligible + ): + retry_limit = failover_decision.same_model_retries + will_retry = error_attempt <= retry_limit + if will_retry and retry_message is None: + retry_message = ( + f"Provider error ({failover_decision.reason}), retrying..." + ) + else: + will_retry = retry_message is not None and error_attempt <= retry_limit + if self._defer_step_errors and not self._attempt_state.replay_safe: + # Retrying after text/reasoning/tool activity can duplicate + # visible output or execute a tool twice. + will_retry = False if will_retry: # Error is retryable and we have budget left @@ -1391,7 +1610,7 @@ async def device_asset_prompt_factory() -> Optional[str]: "attempt": error_attempt, "delay_ms": delay_ms, "reason": retry_message, - "max_retries": MAX_ERROR_RETRIES, + "max_retries": retry_limit, }) # Set retry status @@ -1415,7 +1634,7 @@ async def device_asset_prompt_factory() -> Optional[str]: log.error("runner.step.max_retries_exceeded", { **error_log_context, "attempt": error_attempt, - "max_retries": MAX_ERROR_RETRIES, + "max_retries": retry_limit, }) else: log.error("runner.step.not_retryable", { @@ -1428,6 +1647,15 @@ async def device_asset_prompt_factory() -> Optional[str]: final_error_message = CONNECTION_ERROR_DISPLAY_MESSAGE error_dict["data"]["displayMessage"] = CONNECTION_ERROR_DISPLAY_MESSAGE + if self._defer_step_errors: + return self._deferred_failure_result( + message=final_error_message, + error_data=error_dict, + assistant_message_id=assistant_msg.id, + decision=failover_decision, + attempts=error_attempt, + ) + if self.callbacks.on_error: await self.callbacks.on_error(final_error_message) @@ -2549,6 +2777,16 @@ def _build_llm_response_payload( except Exception as e: log.debug("runner.sandbox_context_init_failed", {"error": str(e)}) + async def _on_tool_execution_start( + tool_name: str, + tool_input: Dict[str, Any], + ) -> None: + # Mark before hooks/callbacks/execution (including parallel tools) + # so a concurrent provider error can never replay side effects. + self._attempt_state.tool_execution_started = True + if self.callbacks.on_tool_start: + await self.callbacks.on_tool_start(tool_name, tool_input) + processor = StreamProcessor( session_id=self.session.id, assistant_message=assistant_msg, @@ -2557,7 +2795,7 @@ def _build_llm_response_payload( permission_callback=self._handle_permission, text_delta_callback=self.callbacks.on_text_delta, reasoning_delta_callback=self.callbacks.on_reasoning_delta, - tool_start_callback=self.callbacks.on_tool_start, + tool_start_callback=_on_tool_execution_start, tool_end_callback=self.callbacks.on_tool_end, event_publish_callback=self.callbacks.event_publish_callback, session_key=self.session.id, @@ -2746,6 +2984,7 @@ def _build_llm_response_payload( ongoing_chunk_timeout_s=LLM_STREAM_ONGOING_CHUNK_TIMEOUT_S, ): chunk_counts["total"] += 1 + self._attempt_state.received_chunk = True if not first_chunk_logged: first_chunk_logged = True self._log_perf( @@ -2784,6 +3023,7 @@ def _build_llm_response_payload( self._current_reasoning_metadata = current_metadata if event_type == "reasoning-start" and not hasattr(self, '_current_reasoning_id'): + self._attempt_state.observable_output_started = True reasoning_id_counter += 1 self._current_reasoning_id = f"reasoning-{reasoning_id_counter}" self._current_reasoning_metadata = dict(chunk_metadata) @@ -2824,6 +3064,7 @@ def _build_llm_response_payload( # 1) Process reasoning delta (start reasoning block on first sight). if chunk_reasoning or (event_type == 'reasoning' and has_reasoning_metadata): + self._attempt_state.observable_output_started = True reasoning_text = chunk_reasoning or "" chunk_counts["reasoning"] += 1 log.debug("runner.reasoning.received", { @@ -2860,6 +3101,7 @@ def _build_llm_response_payload( # 3) Process text delta. if chunk_text: + self._attempt_state.observable_output_started = True chunk_counts["text"] += 1 if not text_started: await processor.process_event(TextStartEvent()) @@ -2871,6 +3113,9 @@ def _build_llm_response_payload( # 4) Process tool calls. if chunk_tool_calls: + # Tool fragments are persisted/accumulated and may become an + # executable call in this same await, so any replay is unsafe. + self._attempt_state.observable_output_started = True chunk_counts["tool"] += 1 for tc in chunk_tool_calls: await tool_accumulator.feed_chunk(tc) diff --git a/flocks/session/session.py b/flocks/session/session.py index b83d898c2..71029d88b 100644 --- a/flocks/session/session.py +++ b/flocks/session/session.py @@ -94,6 +94,13 @@ class SessionInfo(BaseModel): "Unpinned sessions follow the normal default-model resolution chain." ), ) + model_auto: bool = Field( + False, + description=( + "Whether WebUI Auto runtime failover was explicitly selected for " + "this session. Other session entry points ignore this flag." + ), + ) # Session hierarchy parent_id: Optional[str] = Field(None, alias="parentID", description="Parent session for branching") @@ -198,6 +205,7 @@ def explicit_model_updates(provider_id: str, model_id: str) -> Dict[str, Any]: "provider": provider_id, "model": model_id, "model_pinned": True, + "model_auto": False, } @classmethod @@ -605,6 +613,13 @@ async def delete(cls, project_id: str, session_id: str) -> bool: # Soft delete await cls.update(project_id, session_id, status="deleted") cls._id_index.pop(session_id, None) + + # Auto failover cooldowns are process-local session state. Clear them + # here (rather than only in the HTTP route) so recursive child deletes + # and non-HTTP deletion paths cannot retain stale entries indefinitely. + from flocks.session.session_loop import SessionLoop + + SessionLoop.clear_auto_failover_state(session_id) # Clear messages await Message.clear(session_id) diff --git a/flocks/session/session_loop.py b/flocks/session/session_loop.py index adb1e112e..a5616d06c 100644 --- a/flocks/session/session_loop.py +++ b/flocks/session/session_loop.py @@ -47,6 +47,26 @@ MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3 POST_COMPACTION_COOLDOWN_STEPS = 2 +RATE_LIMIT_COOLDOWN_SECONDS = 60.0 +CHAIN_EXHAUSTION_COOLDOWN_SECONDS = 5.0 + + +@dataclass(frozen=True) +class RuntimeModel: + """Concrete provider/model candidate used by Auto failover.""" + + provider_id: str + model_id: str + + +@dataclass +class AutoFailoverCooldown: + """Process-local Hermes-style starting candidate cooldown.""" + + model: RuntimeModel + primary: RuntimeModel + expires_at: float + reason: str @dataclass @@ -81,6 +101,13 @@ class LoopContext: # is what the upstream will actually bill us for on the next turn # (matches the "observed value wins" rule from docs/design/context-compaction-v2.md §B3). last_observed_prompt_tokens: int = 0 + auto_failover: bool = False + # Entrypoint authorization is separate from persisted model_auto. Only a + # WebUI message route may set this bit; IM/tasks/workflows use the default. + auto_failover_allowed: bool = False + model_candidates: List[RuntimeModel] = field(default_factory=list) + candidate_index: int = 0 + turn_user_id: Optional[str] = None @property def trace_step(self) -> int: @@ -120,6 +147,8 @@ class LoopResult: action: str # "stop", "continue", "compact", "error", "queued" last_message: Optional[MessageInfo] = None error: Optional[str] = None + provider_id: Optional[str] = None + model_id: Optional[str] = None metadata: Dict[str, Any] = field(default_factory=dict) @@ -137,6 +166,155 @@ class SessionLoop: # Active loop contexts by session ID _active_loops: Dict[str, LoopContext] = {} + _auto_failover_cooldowns: Dict[str, AutoFailoverCooldown] = {} + + @classmethod + def clear_auto_failover_state(cls, session_id: str) -> None: + """Clear process-local routing state when WebUI Auto is disabled.""" + cls._auto_failover_cooldowns.pop(session_id, None) + + @classmethod + async def validate_runtime_model( + cls, + provider_id: str, + model_id: str, + *, + config: Optional[Any] = None, + ) -> tuple[bool, str]: + """Validate a configured LLM candidate without a network health probe.""" + from flocks.config.config import Config + from flocks.provider.model_manager import get_model_manager + from flocks.provider.types import ModelType + + Provider._ensure_initialized() + config = config or await Config.get() + if provider_id in (getattr(config, "disabled_providers", None) or []): + return False, "provider_disabled" + enabled_providers = getattr(config, "enabled_providers", None) or [] + if enabled_providers and provider_id not in enabled_providers: + return False, "provider_disabled" + try: + await Provider.apply_config(config, provider_id=provider_id) + except Exception as exc: + log.warn("session.model.candidate_config_failed", { + "provider_id": provider_id, + "model_id": model_id, + "error": str(exc), + }) + return False, "provider_config_error" + + provider = Provider.get(provider_id) + if provider is None: + return False, "provider_not_found" + + definition = get_model_manager().get_model(provider_id, model_id) + if definition is None: + return False, "model_not_found" + if getattr(definition, "model_type", None) != ModelType.LLM: + return False, "not_llm" + + setting = get_model_manager().get_setting(provider_id, model_id) + if setting is not None and not setting.enabled: + return False, "model_disabled" + if not provider.is_configured(): + return False, "provider_not_configured" + return True, "available" + + @classmethod + async def _build_model_candidates( + cls, + primary: RuntimeModel, + ) -> List[RuntimeModel]: + """Build primary + ordered valid fallbacks, preserving configured order.""" + from flocks.config.config import Config + + config = await Config.get() + candidates = [primary] + seen = {(primary.provider_id, primary.model_id)} + for index, raw in enumerate(getattr(config, "fallback_providers", None) or []): + candidate = RuntimeModel( + provider_id=raw.provider_id, + model_id=raw.model_id, + ) + identity = (candidate.provider_id, candidate.model_id) + if identity in seen: + continue + seen.add(identity) + available, reason = await cls.validate_runtime_model( + candidate.provider_id, + candidate.model_id, + config=config, + ) + if not available: + log.warn("session.model.fallback_skipped", { + "provider_id": candidate.provider_id, + "model_id": candidate.model_id, + "configured_index": index, + "reason": reason, + }) + continue + candidates.append(candidate) + return candidates + + @classmethod + async def validate_auto_configuration(cls) -> tuple[bool, str]: + """Validate that a newly selected Auto mode has a usable chain.""" + from flocks.config.config import Config + + default_llm = await Config.resolve_default_llm() + if not default_llm: + return False, "default_model_missing" + primary = RuntimeModel( + default_llm["provider_id"], + default_llm["model_id"], + ) + available, reason = await cls.validate_runtime_model( + primary.provider_id, + primary.model_id, + ) + if not available: + return False, f"primary_{reason}" + if len(await cls._build_model_candidates(primary)) < 2: + return False, "fallback_unavailable" + return True, "available" + + @classmethod + def _cooldown_candidate_index( + cls, + session_id: str, + candidates: List[RuntimeModel], + ) -> int: + cooldown = cls._auto_failover_cooldowns.get(session_id) + if cooldown is None: + return 0 + if cooldown.expires_at <= time.monotonic(): + cls._auto_failover_cooldowns.pop(session_id, None) + return 0 + if not candidates or candidates[0] != cooldown.primary: + cls._auto_failover_cooldowns.pop(session_id, None) + return 0 + try: + return candidates.index(cooldown.model) + except ValueError: + cls._auto_failover_cooldowns.pop(session_id, None) + return 0 + + @classmethod + def _select_candidate(cls, ctx: LoopContext, index: int) -> None: + candidate = ctx.model_candidates[index] + ctx.candidate_index = index + ctx.provider_id = candidate.provider_id + ctx.model_id = candidate.model_id + ctx.session.provider = candidate.provider_id + ctx.session.model = candidate.model_id + # Prompt and model-capability caches are keyed in most places, but a + # fresh dict makes the runtime rebuild guarantee explicit. The tool + # loop guard is turn state rather than model state, so it must survive + # a provider switch to keep repeated-tool protection effective. + tool_loop_guard = ctx.runner_static_cache.get("tool_loop_guard") + ctx.runner_static_cache.clear() + if tool_loop_guard is not None: + ctx.runner_static_cache["tool_loop_guard"] = tool_loop_guard @classmethod def is_running(cls, session_id: str) -> bool: @@ -298,6 +476,7 @@ async def run( agent_name: Optional[str] = None, callbacks: Optional[LoopCallbacks] = None, working_directory: Optional[str] = None, + auto_failover: bool = False, ) -> LoopResult: """ Run session loop @@ -327,6 +506,13 @@ async def run( # next iteration once it finishes the current step. if cls.is_running(session_id): log.info("loop.already_running", {"session_id": session_id}) + if auto_failover: + active_ctx = cls._active_loops.get(session_id) + if ( + active_ctx is not None + and getattr(active_ctx.session, "category", "user") == "user" + ): + active_ctx.auto_failover_allowed = True return LoopResult( action="queued", error="Loop already running", @@ -351,6 +537,25 @@ async def run( provider_id = provider_id or resolved_provider model_id = model_id or resolved_model + primary_model = RuntimeModel( + provider_id=provider_id, + model_id=model_id, + ) + model_candidates = [primary_model] + candidate_index = 0 + auto_failover = bool( + auto_failover and getattr(session, "category", "user") == "user" + ) + if auto_failover: + model_candidates = await cls._build_model_candidates(primary_model) + candidate_index = cls._cooldown_candidate_index( + session_id, + model_candidates, + ) + active_candidate = model_candidates[candidate_index] + provider_id = active_candidate.provider_id + model_id = active_candidate.model_id + # Keep the in-memory session aligned with the runtime model so # downstream helpers (title generation, compaction checks, etc.) see # the model actually selected for this loop iteration. Unpinned @@ -382,6 +587,10 @@ async def run( agent_name=agent_name or session.agent or "rex", session_ctx=session_ctx, trace_step_offset=trace_offset, + auto_failover=auto_failover, + auto_failover_allowed=auto_failover, + model_candidates=model_candidates, + candidate_index=candidate_index, ) # Register context @@ -424,7 +633,12 @@ async def run( }) except Exception as exc: log.warn("loop.error.event_error", {"error": str(exc)}) - return LoopResult(action="error", error=str(e)) + return LoopResult( + action="error", + error=str(e), + provider_id=ctx.provider_id, + model_id=ctx.model_id, + ) finally: # Clean up if session_id in cls._active_loops: @@ -562,6 +776,236 @@ async def _resolve_model( return resolved_provider, resolved_model, source return resolved_provider, resolved_model + @classmethod + async def _prepare_auto_turn( + cls, + ctx: LoopContext, + last_user: MessageInfo, + ) -> None: + """Synchronize routing when the loop advances to a real WebUI turn.""" + if ctx.turn_user_id is None: + ctx.turn_user_id = last_user.id + return + if last_user.id == ctx.turn_user_id: + return + + parts = await Message.parts(last_user.id, ctx.session.id) + if any(bool(getattr(part, "synthetic", False)) for part in parts): + return + + ctx.turn_user_id = last_user.id + persisted_session = await Session.get_by_id(ctx.session.id) + persisted_model_auto = bool( + persisted_session + and getattr(persisted_session, "category", "user") == "user" + and getattr(persisted_session, "model_auto", False) + ) + persisted_auto = persisted_model_auto and ctx.auto_failover_allowed + + user_model = getattr(last_user, "model", None) + user_provider_id = None + user_model_id = None + if isinstance(user_model, dict): + user_provider_id = user_model.get("providerID") or user_model.get("provider_id") + user_model_id = user_model.get("modelID") or user_model.get("model_id") + + if not persisted_auto: + ctx.auto_failover = False + if not persisted_model_auto: + cls.clear_auto_failover_state(ctx.session.id) + ctx.auto_failover_allowed = False + provider_id = ( + getattr(persisted_session, "provider", None) + if Session.has_pinned_model(persisted_session) + else user_provider_id + ) or ctx.provider_id + model_id = ( + getattr(persisted_session, "model", None) + if Session.has_pinned_model(persisted_session) + else user_model_id + ) or ctx.model_id + ctx.model_candidates = [RuntimeModel(provider_id, model_id)] + cls._select_candidate(ctx, 0) + log.info("session.model.auto_disabled_for_turn", { + "session_id": ctx.session.id, + "provider_id": provider_id, + "model_id": model_id, + }) + return + + from flocks.config.config import Config + + previous = RuntimeModel(ctx.provider_id, ctx.model_id) + default_llm = await Config.resolve_default_llm() + primary = RuntimeModel( + provider_id=(default_llm or {}).get("provider_id") or user_provider_id or ctx.provider_id, + model_id=(default_llm or {}).get("model_id") or user_model_id or ctx.model_id, + ) + # Rebuild for every real turn so disabled/removed fallbacks never leak + # from an earlier queued message. Synthetic continuations return above. + ctx.model_candidates = await cls._build_model_candidates(primary) + ctx.auto_failover = True + next_index = cls._cooldown_candidate_index( + ctx.session.id, + ctx.model_candidates, + ) + cls._select_candidate(ctx, next_index) + active = ctx.model_candidates[next_index] + log.info("session.model.auto_turn_reset", { + "session_id": ctx.session.id, + "from_provider_id": previous.provider_id, + "from_model_id": previous.model_id, + "to_provider_id": active.provider_id, + "to_model_id": active.model_id, + "cooldown_active": next_index > 0, + }) + + @classmethod + async def _finalize_deferred_failure( + cls, + ctx: LoopContext, + failure: Any, + ) -> None: + """Persist only the final Auto candidate failure.""" + if not failure.assistant_message_id: + return + await Message.update( + ctx.session.id, + failure.assistant_message_id, + error=failure.error_data, + finish="error", + ) + + @classmethod + async def _process_step_with_failover( + cls, + ctx: LoopContext, + callbacks: LoopCallbacks, + messages: List[MessageInfo], + last_user: MessageInfo, + ) -> Any: + """Run one logical step, moving across candidates without replaying output.""" + from flocks.session.runner import RunnerCallbacks, SessionRunner + + while True: + runner_cbs = callbacks.runner_callbacks + if runner_cbs is None: + runner_cbs = RunnerCallbacks() + if callbacks.event_publish_callback and not runner_cbs.event_publish_callback: + runner_cbs.event_publish_callback = callbacks.event_publish_callback + + runner = SessionRunner( + session=ctx.session, + provider_id=ctx.provider_id, + model_id=ctx.model_id, + agent_name=ctx.agent_name, + abort_event=ctx.abort_event, + callbacks=runner_cbs, + session_ctx=ctx.session_ctx, + memory_bootstrap_data=ctx.memory_bootstrap_data, + static_cache=ctx.runner_static_cache, + defer_step_errors=ctx.auto_failover, + failover_available=( + ctx.auto_failover + and ctx.candidate_index + 1 < len(ctx.model_candidates) + ), + ) + runner._step = ctx.trace_step + + step_result = await runner._process_step(messages, last_user) + failure = step_result.failure + if not ctx.auto_failover or failure is None: + return step_result + + next_index = ctx.candidate_index + 1 + has_next = next_index < len(ctx.model_candidates) + if not failure.allow_fallback or not has_next: + if ( + failure.allow_fallback + and not has_next + and ctx.candidate_index > 0 + and failure.reason not in {"rate_limit", "billing"} + ): + expires_at = time.monotonic() + CHAIN_EXHAUSTION_COOLDOWN_SECONDS + existing_cooldown = cls._auto_failover_cooldowns.get(ctx.session.id) + if not ( + existing_cooldown + and existing_cooldown.expires_at > expires_at + ): + cls._auto_failover_cooldowns[ctx.session.id] = AutoFailoverCooldown( + model=ctx.model_candidates[ctx.candidate_index], + primary=ctx.model_candidates[0], + expires_at=expires_at, + reason="chain_exhausted", + ) + await cls._finalize_deferred_failure(ctx, failure) + return step_result + + # A candidate may be removed only while its attempt is completely + # replay-safe. Failure to delete stops the switch to avoid leaving + # two assistant cards for one logical response. + if failure.assistant_message_id: + try: + deleted = await Message.delete( + ctx.session.id, + failure.assistant_message_id, + ) + except Exception as exc: + deleted = False + log.error("session.model.fallback_cleanup_failed", { + "session_id": ctx.session.id, + "message_id": failure.assistant_message_id, + "error": str(exc), + }) + if not deleted: + await cls._finalize_deferred_failure(ctx, failure) + return step_result + await cls._publish_runtime_event(callbacks, "message.removed", { + "sessionID": ctx.session.id, + "messageID": failure.assistant_message_id, + }) + + previous = ctx.model_candidates[ctx.candidate_index] + next_candidate = ctx.model_candidates[next_index] + + if ctx.candidate_index == 0 and failure.reason in {"rate_limit", "billing"}: + cls._auto_failover_cooldowns[ctx.session.id] = AutoFailoverCooldown( + model=next_candidate, + primary=ctx.model_candidates[0], + expires_at=time.monotonic() + RATE_LIMIT_COOLDOWN_SECONDS, + reason=failure.reason, + ) + else: + cooldown = cls._auto_failover_cooldowns.get(ctx.session.id) + if cooldown and cooldown.expires_at > time.monotonic(): + cooldown.model = next_candidate + + cls._select_candidate(ctx, next_index) + event_payload = { + "sessionID": ctx.session.id, + "from": { + "providerID": previous.provider_id, + "modelID": previous.model_id, + }, + "to": { + "providerID": next_candidate.provider_id, + "modelID": next_candidate.model_id, + }, + "reason": failure.reason, + "candidateIndex": next_index, + } + log.warn("session.model.fallback", { + "from": event_payload["from"], + "to": event_payload["to"], + "reason": event_payload["reason"], + "candidateIndex": event_payload["candidateIndex"], + }) + await cls._publish_runtime_event( + callbacks, + "session.model.fallback", + event_payload, + ) + @classmethod async def _run_loop( cls, @@ -581,6 +1025,7 @@ async def _run_loop( 7. Loop until complete """ last_message: Optional[MessageInfo] = None + loop_error: Optional[str] = None while not ctx.should_abort(): # Set status to busy @@ -678,6 +1123,8 @@ async def _run_loop( stop_reason="no_user_message", ) break + + await cls._prepare_auto_turn(ctx, last_user) last_assistant_parts = ( await Message.parts(last_assistant.id, ctx.session.id) @@ -720,7 +1167,7 @@ async def _run_loop( # may cancel this task before it finishes). # generate_title_after_first_message is idempotent: if this task saves # the title first, the safety-net call returns immediately. - if ctx.step == 1: + if ctx.step == 1 and not ctx.auto_failover: try: from flocks.session.lifecycle.title import SessionTitle # UserMessageInfo.model is Dict[str, str] {"providerID": ..., "modelID": ...} @@ -1207,34 +1654,16 @@ async def progress_callback_overflow(stage: str, data: dict) -> None: except Exception as e: log.error("loop.compaction_overflow_check_error", {"error": str(e)}) - # Process step - delegate to runner (matching TUI SessionProcessor.process) - from flocks.session.runner import SessionRunner, RunnerCallbacks - - # Build runner callbacks from loop callbacks - runner_cbs = callbacks.runner_callbacks - if runner_cbs is None: - runner_cbs = RunnerCallbacks() - # Ensure event_publish_callback is propagated - if callbacks.event_publish_callback and not runner_cbs.event_publish_callback: - runner_cbs.event_publish_callback = callbacks.event_publish_callback - - runner = SessionRunner( - session=ctx.session, - provider_id=ctx.provider_id, - model_id=ctx.model_id, - agent_name=ctx.agent_name, - abort_event=ctx.abort_event, - callbacks=runner_cbs, - session_ctx=ctx.session_ctx, - memory_bootstrap_data=ctx.memory_bootstrap_data, - static_cache=ctx.runner_static_cache, - ) - # Use session-cumulative step number for observability. - runner._step = ctx.trace_step - # Process single step — wrap in a Task so abort() can cancel it immediately # rather than waiting for the current tool call to finish. - step_task = asyncio.create_task(runner._process_step(messages, last_user)) + step_task = asyncio.create_task( + cls._process_step_with_failover( + ctx, + callbacks, + messages, + last_user, + ) + ) ctx._current_step_task = step_task step_started_at = asyncio.get_event_loop().time() try: @@ -1256,6 +1685,7 @@ async def progress_callback_overflow(stage: str, data: dict) -> None: # Handle result if step_result.action == "stop": + loop_error = step_result.error # Report error if step failed if step_result.error and callbacks.on_error: await callbacks.on_error(step_result.error) @@ -1266,7 +1696,13 @@ async def progress_callback_overflow(stage: str, data: dict) -> None: else: post_messages = await Message.list(ctx.session.id) for msg in reversed(post_messages): - if msg.role == MessageRole.ASSISTANT: + if ( + msg.role == MessageRole.ASSISTANT + and ( + not ctx.auto_failover + or getattr(msg, "parentID", None) == last_user.id + ) + ): last_message = msg break @@ -1429,8 +1865,11 @@ async def progress_callback_overflow(stage: str, data: dict) -> None: # Return result return LoopResult( - action="stop", + action="error" if ctx.auto_failover and loop_error else "stop", last_message=last_message, + error=loop_error if ctx.auto_failover else None, + provider_id=ctx.provider_id, + model_id=ctx.model_id, metadata={ "steps": ctx.step, "session_id": ctx.session.id, @@ -1709,6 +2148,7 @@ async def _execute_subtask( agent=last_user.agent if hasattr(last_user, 'agent') else agent_name, model=last_user.model if hasattr(last_user, 'model') else model_id, provider=last_user.provider if hasattr(last_user, 'provider') else provider_id, + synthetic=True, ) log.info("loop.subtask.completed", { diff --git a/tests/agent/test_unified_session_loop.py b/tests/agent/test_unified_session_loop.py index 255882ccd..45797e949 100644 --- a/tests/agent/test_unified_session_loop.py +++ b/tests/agent/test_unified_session_loop.py @@ -8,6 +8,8 @@ 4. _resolve_model implements 5-level priority correctly """ +import asyncio + import pytest from unittest.mock import AsyncMock, MagicMock, patch from dataclasses import dataclass @@ -279,7 +281,221 @@ async def test_process_session_message_pins_explicit_request_model(self, monkeyp provider="anthropic", model="claude-sonnet-4-5", model_pinned=True, + model_auto=False, + ) + + @pytest.mark.asyncio + async def test_webui_auto_uses_default_primary_and_returns_actual_model(self, monkeypatch): + """Auto ignores agent overrides and reports the model that recovered.""" + from types import SimpleNamespace + + from flocks.server.routes import session as session_routes + from flocks.session.session_loop import LoopResult, SessionLoop + + request = session_routes.PromptRequest( + parts=[{"type": "text", "text": "hello"}], ) + session = SimpleNamespace( + id="ses_auto", + project_id="proj", + directory="/tmp/project", + agent="rex", + provider="stale", + model="stale-model", + model_pinned=False, + model_auto=True, + category="user", + ) + agent = SimpleNamespace( + name="rex", + model={"providerID": "agent-provider", "modelID": "agent-model"}, + ) + fallback_message = SimpleNamespace( + id="msg_assistant", + providerID="fallback", + modelID="fallback-model", + finish="stop", + tokens=None, + ) + loop_run = AsyncMock(return_value=LoopResult( + action="stop", + last_message=fallback_message, + provider_id="fallback", + model_id="fallback-model", + )) + message_create = AsyncMock(return_value=SimpleNamespace(id="msg_user")) + context_usage_update = AsyncMock() + title_generation = AsyncMock() + + monkeypatch.setattr(session_routes, "_require_agent_usable_for_chat", AsyncMock()) + monkeypatch.setattr( + "flocks.agent.registry.Agent.default_agent", + AsyncMock(return_value="rex"), + ) + monkeypatch.setattr("flocks.agent.registry.Agent.get", AsyncMock(return_value=agent)) + monkeypatch.setattr( + "flocks.config.config.Config.resolve_default_llm", + AsyncMock(return_value={ + "provider_id": "primary", + "model_id": "primary-model", + }), + ) + monkeypatch.setattr( + "flocks.config.config.Config.get", + AsyncMock(return_value=SimpleNamespace()), + ) + monkeypatch.setattr( + SessionLoop, + "validate_runtime_model", + AsyncMock(return_value=(True, "available")), + ) + monkeypatch.setattr(SessionLoop, "run", loop_run) + monkeypatch.setattr("flocks.provider.provider.Provider._ensure_initialized", lambda: None) + monkeypatch.setattr("flocks.provider.provider.Provider.apply_config", AsyncMock()) + monkeypatch.setattr("flocks.provider.provider.Provider.get", lambda _provider_id: object()) + monkeypatch.setattr("flocks.tool.registry.ToolRegistry.init", lambda: None) + monkeypatch.setattr( + "flocks.session.lifecycle.revert.SessionRevert.cleanup", + AsyncMock(), + ) + monkeypatch.setattr("flocks.session.message.Message.create", message_create) + monkeypatch.setattr( + "flocks.session.message.Message.get_text_content", + AsyncMock(return_value="recovered"), + ) + monkeypatch.setattr("flocks.session.message.Message.parts", AsyncMock(return_value=[])) + monkeypatch.setattr("flocks.server.routes.event.publish_event", AsyncMock()) + monkeypatch.setattr( + session_routes, + "_publish_context_usage_update", + context_usage_update, + ) + monkeypatch.setattr( + "flocks.session.lifecycle.title.SessionTitle.generate_title_after_first_message", + title_generation, + ) + + response = await session_routes._process_session_message( + session.id, + session, + request, + session.directory, + ) + + assert message_create.await_args.kwargs["model"] == { + "providerID": "primary", + "modelID": "primary-model", + } + assert loop_run.await_args.kwargs["auto_failover"] is True + assert loop_run.await_args.kwargs["provider_id"] == "primary" + assert response["info"]["providerID"] == "fallback" + assert response["info"]["modelID"] == "fallback-model" + assert context_usage_update.await_args.kwargs["provider_id"] == "fallback" + assert context_usage_update.await_args.kwargs["model_id"] == "fallback-model" + await asyncio.sleep(0) + assert title_generation.await_args.kwargs["provider_id"] == "fallback" + assert title_generation.await_args.kwargs["model_id"] == "fallback-model" + + @pytest.mark.asyncio + async def test_non_user_session_ignores_legacy_auto_flag(self, monkeypatch): + """Corrupt or historical workflow flags cannot activate WebUI Auto.""" + from types import SimpleNamespace + + from flocks.server.routes import session as session_routes + from flocks.session.session_loop import SessionLoop + + request = session_routes.PromptRequest( + parts=[{"type": "text", "text": "workflow input"}], + noReply=True, + ) + session = SimpleNamespace( + id="ses_workflow", + project_id="proj", + directory="/tmp/project", + agent="rex", + provider="direct", + model="direct-model", + model_pinned=True, + model_auto=True, + category="workflow", + ) + agent = SimpleNamespace(name="rex", model=None) + resolve = AsyncMock(return_value=("direct", "direct-model", "session")) + default_llm = AsyncMock() + validate = AsyncMock() + message_create = AsyncMock(return_value=SimpleNamespace(id="msg_user")) + + monkeypatch.setattr( + session_routes, + "_require_agent_usable_for_chat", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.agent.registry.Agent.default_agent", + AsyncMock(return_value="rex"), + ) + monkeypatch.setattr( + "flocks.agent.registry.Agent.get", + AsyncMock(return_value=agent), + ) + monkeypatch.setattr(session_routes, "_resolve_model", resolve) + monkeypatch.setattr( + "flocks.config.config.Config.resolve_default_llm", + default_llm, + ) + monkeypatch.setattr( + "flocks.config.config.Config.get", + AsyncMock(return_value=SimpleNamespace()), + ) + monkeypatch.setattr(SessionLoop, "validate_runtime_model", validate) + monkeypatch.setattr( + "flocks.provider.provider.Provider._ensure_initialized", + lambda: None, + ) + monkeypatch.setattr( + "flocks.provider.provider.Provider.apply_config", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.provider.provider.Provider.get", + lambda _provider_id: object(), + ) + monkeypatch.setattr("flocks.tool.registry.ToolRegistry.init", lambda: None) + monkeypatch.setattr( + "flocks.session.lifecycle.revert.SessionRevert.cleanup", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.session.message.Message.create", + message_create, + ) + monkeypatch.setattr( + "flocks.server.routes.event.publish_event", + AsyncMock(), + ) + context_usage = AsyncMock() + monkeypatch.setattr( + session_routes, + "_publish_context_usage_update", + context_usage, + ) + + await session_routes._process_session_message( + session.id, + session, + request, + session.directory, + ) + + resolve.assert_awaited_once() + default_llm.assert_not_awaited() + validate.assert_not_awaited() + assert message_create.await_args.kwargs["model"] == { + "providerID": "direct", + "modelID": "direct-model", + } + assert context_usage.await_args.kwargs["provider_id"] == "direct" + assert context_usage.await_args.kwargs["model_id"] == "direct-model" @pytest.mark.asyncio async def test_display_text_does_not_replace_model_prompt(self, monkeypatch): diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index ca43b75fb..bcc69db46 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -495,6 +495,7 @@ async def fake_update(project_id, session_id, **updates): "provider": "anthropic", "model": "claude-sonnet-4-20250514", "model_pinned": True, + "model_auto": False, }, ) ] diff --git a/tests/config/test_config_writer.py b/tests/config/test_config_writer.py index 1a136f4cf..1940701c5 100644 --- a/tests/config/test_config_writer.py +++ b/tests/config/test_config_writer.py @@ -446,3 +446,100 @@ def test_default_models_preserve_other_sections(self, temp_project): data = ConfigWriter._read_raw() assert "provider" in data assert "mcp" in data + + +class TestFallbackProviderConfigWriter: + """Test ordered runtime fallback model configuration.""" + + def test_get_fallback_providers_empty(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + assert ConfigWriter.get_fallback_providers() == [] + + def test_set_and_get_fallback_providers_preserves_order(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + fallbacks = [ + {"provider_id": "anthropic", "model_id": "claude-sonnet-4-5"}, + {"provider_id": "openrouter", "model_id": "vendor/model-v2"}, + ] + ConfigWriter.set_fallback_providers(fallbacks) + + assert ConfigWriter.get_fallback_providers() == fallbacks + + def test_set_fallback_providers_preserves_small_model(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + raw = ConfigWriter._read_raw() + raw["smallModel"] = "anthropic/claude-haiku" + ConfigWriter._write_raw(raw) + + ConfigWriter.set_fallback_providers([ + {"provider_id": "anthropic", "model_id": "claude-sonnet-4-5"}, + ]) + + updated = ConfigWriter._read_raw() + assert updated["smallModel"] == "anthropic/claude-haiku" + + def test_empty_fallback_providers_removes_key(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + raw = ConfigWriter._read_raw() + raw["smallModel"] = "anthropic/claude-haiku" + raw["fallback_providers"] = [ + {"provider_id": "anthropic", "model_id": "claude-sonnet-4-5"}, + ] + ConfigWriter._write_raw(raw) + + ConfigWriter.set_fallback_providers([]) + + updated = ConfigWriter._read_raw() + assert "fallback_providers" not in updated + assert updated["smallModel"] == "anthropic/claude-haiku" + + def test_get_skips_malformed_and_duplicate_entries_without_rewriting( + self, temp_project + ): + from flocks.config.config_writer import ConfigWriter + + raw = ConfigWriter._read_raw() + raw_entries = [ + {"provider_id": " anthropic ", "model_id": " claude-sonnet-4-5 "}, + {"provider_id": "anthropic", "model_id": "claude-sonnet-4-5"}, + {"provider_id": "", "model_id": "empty-provider"}, + {"provider_id": "openai"}, + "not-an-object", + {"provider_id": "stale", "model_id": "removed/model"}, + ] + raw["fallback_providers"] = raw_entries + ConfigWriter._write_raw(raw) + + assert ConfigWriter.get_fallback_providers() == [ + {"provider_id": "anthropic", "model_id": "claude-sonnet-4-5"}, + {"provider_id": "stale", "model_id": "removed/model"}, + ] + assert ConfigWriter._read_raw()["fallback_providers"] == raw_entries + + def test_typed_config_skips_malformed_fallbacks_and_keeps_small_model(self): + from flocks.config.config import ConfigInfo + + config = ConfigInfo.model_validate({ + "smallModel": "anthropic/claude-haiku", + "fallback_providers": [ + { + "provider_id": " openrouter ", + "model_id": " vendor/model-v2 ", + }, + {"provider_id": "openrouter", "model_id": "vendor/model-v2"}, + {"provider_id": "", "model_id": "missing-provider"}, + {"provider_id": "openai"}, + None, + {"provider_id": "stale", "model_id": "removed-model"}, + ], + }) + + assert config.small_model == "anthropic/claude-haiku" + assert [entry.model_dump() for entry in config.fallback_providers or []] == [ + {"provider_id": "openrouter", "model_id": "vendor/model-v2"}, + {"provider_id": "stale", "model_id": "removed-model"}, + ] diff --git a/tests/provider/test_provider_apply_config_idempotent.py b/tests/provider/test_provider_apply_config_idempotent.py index b00af163d..cff5a44b8 100644 --- a/tests/provider/test_provider_apply_config_idempotent.py +++ b/tests/provider/test_provider_apply_config_idempotent.py @@ -171,3 +171,64 @@ async def test_apply_config_still_mutates_when_input_changes() -> None: assert rec.configure_calls == 1, ( f"expected exactly 1 configure call on api_key change, got {rec.configure_calls}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options", + [ + None, + SimpleNamespace( + model_dump=lambda exclude_none, by_alias: { + "api_key": " ", + "base_url": "", + } + ), + ], + ids=["missing-options", "empty-credentials"], +) +async def test_apply_config_loads_name_and_models_without_credentials( + options: Any, +) -> None: + """Provider metadata must not depend on resolved credentials.""" + Provider._ensure_initialized() + provider = Provider.get("openai-compatible") + assert provider is not None + + original_config = provider._config + original_models = list(getattr(provider, "_config_models", []) or []) + original_name = provider.name + model_id = f"credentialless-model-{id(options)}" + fake_cfg = SimpleNamespace( + provider={ + "openai-compatible": SimpleNamespace( + name="Credentialless Provider", + options=options, + models={ + model_id: { + "name": "Credentialless Model", + "supports_tools": True, + } + }, + ) + } + ) + + try: + with _Recorder(provider) as first: + await Provider.apply_config(fake_cfg, provider_id="openai-compatible") + + assert first.configure_calls == 0 + assert first.models_assignments == 1 + assert provider.name == "Credentialless Provider" + assert [model.id for model in provider._config_models] == [model_id] + + with _Recorder(provider) as second: + await Provider.apply_config(fake_cfg, provider_id="openai-compatible") + + assert second.configure_calls == 0 + assert second.models_assignments == 0 + finally: + provider._config = original_config + provider._config_models = original_models + provider.name = original_name diff --git a/tests/server/routes/test_default_model_fallbacks.py b/tests/server/routes/test_default_model_fallbacks.py new file mode 100644 index 000000000..20d10e21f --- /dev/null +++ b/tests/server/routes/test_default_model_fallbacks.py @@ -0,0 +1,305 @@ +"""Tests for ordered default-model fallback configuration routes.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from httpx import AsyncClient + +from flocks.provider.provider import Provider +from flocks.provider.types import ModelType +from flocks.server.routes import default_model as default_model_routes + + +class _ModelManagerStub: + """Small ModelManager stub used to isolate fallback route validation.""" + + def __init__(self, models, disabled=None): + self._models = models + self._disabled = set(disabled or []) + + def get_model(self, provider_id: str, model_id: str): + return self._models.get((provider_id, model_id)) + + def get_setting(self, provider_id: str, model_id: str): + identity = (provider_id, model_id) + if identity in self._disabled: + return SimpleNamespace(enabled=False) + return None + + +def _definition(model_type: ModelType = ModelType.LLM): + return SimpleNamespace(model_type=model_type) + + +@pytest.fixture +def fallback_route_stubs(monkeypatch: pytest.MonkeyPatch): + """Prevent fallback route tests from reading or writing real model state.""" + writer = MagicMock() + writer.get_fallback_providers.return_value = [] + runtime_config = SimpleNamespace( + provider={}, + disabled_providers=[], + enabled_providers=None, + ) + apply_config = AsyncMock() + monkeypatch.setattr(default_model_routes, "ConfigWriter", writer) + monkeypatch.setattr( + default_model_routes.Config, + "get", + AsyncMock(return_value=runtime_config), + ) + monkeypatch.setattr( + default_model_routes.Config, + "resolve_default_llm", + AsyncMock( + return_value={ + "provider_id": "anthropic", + "model_id": "claude-primary", + } + ), + ) + monkeypatch.setattr(Provider, "apply_config", apply_config) + writer.runtime_config = runtime_config + writer.apply_config = apply_config + return writer + + +@pytest.mark.asyncio +async def test_get_fallbacks_uses_static_route_and_retains_stale_entries( + client: AsyncClient, + fallback_route_stubs: MagicMock, +): + fallback_route_stubs.get_fallback_providers.return_value = [ + {"provider_id": "removed-provider", "model_id": "vendor/removed-model"}, + ] + + response = await client.get("/api/default-model/fallbacks") + + assert response.status_code == 200 + assert response.json() == { + "fallback_providers": [ + { + "provider_id": "removed-provider", + "model_id": "vendor/removed-model", + } + ] + } + + +@pytest.mark.asyncio +async def test_put_fallbacks_normalizes_and_preserves_order( + client: AsyncClient, + fallback_route_stubs: MagicMock, + monkeypatch: pytest.MonkeyPatch, +): + models = { + ("openai", "gpt-4o"): _definition(), + ("openrouter", "vendor/model-v2"): _definition(), + } + monkeypatch.setattr( + default_model_routes, + "get_model_manager", + lambda: _ModelManagerStub(models), + ) + + response = await client.put( + "/api/default-model/fallbacks", + json={ + "fallback_providers": [ + {"provider_id": " openai ", "model_id": " gpt-4o "}, + { + "provider_id": "openrouter", + "model_id": "vendor/model-v2", + }, + ] + }, + ) + + expected = [ + {"provider_id": "openai", "model_id": "gpt-4o"}, + {"provider_id": "openrouter", "model_id": "vendor/model-v2"}, + ] + assert response.status_code == 200 + assert response.json() == {"fallback_providers": expected} + fallback_route_stubs.set_fallback_providers.assert_called_once_with(expected) + + +@pytest.mark.asyncio +async def test_put_fallbacks_loads_config_models_before_validation( + client: AsyncClient, + fallback_route_stubs: MagicMock, + monkeypatch: pytest.MonkeyPatch, +): + """Cold-start validation sees config models even without credentials.""" + identity = ("openai-compatible", "configured-model") + models = {} + manager = _ModelManagerStub(models) + monkeypatch.setattr( + default_model_routes, + "get_model_manager", + lambda: manager, + ) + fallback_route_stubs.runtime_config.provider = { + "openai-compatible": SimpleNamespace( + options=SimpleNamespace(api_key=None, base_url=None), + models={identity[1]: SimpleNamespace(name="Configured Model")}, + ) + } + + async def load_config_models(config): + assert config is fallback_route_stubs.runtime_config + models[identity] = _definition() + + fallback_route_stubs.apply_config.side_effect = load_config_models + + response = await client.put( + "/api/default-model/fallbacks", + json={ + "fallback_providers": [ + {"provider_id": identity[0], "model_id": identity[1]} + ] + }, + ) + + assert response.status_code == 200 + fallback_route_stubs.apply_config.assert_awaited_once_with( + fallback_route_stubs.runtime_config + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("disabled_providers", "enabled_providers", "expected_status"), + [ + (["openai"], None, 400), + ([], ["anthropic"], 400), + ([], [], 200), + ], + ids=["explicitly-disabled", "excluded-by-enabled", "empty-enabled-allows"], +) +async def test_put_fallbacks_honors_provider_filters( + client: AsyncClient, + fallback_route_stubs: MagicMock, + monkeypatch: pytest.MonkeyPatch, + disabled_providers, + enabled_providers, + expected_status, +): + fallback_route_stubs.runtime_config.disabled_providers = disabled_providers + fallback_route_stubs.runtime_config.enabled_providers = enabled_providers + monkeypatch.setattr( + default_model_routes, + "get_model_manager", + lambda: _ModelManagerStub({("openai", "gpt-4o"): _definition()}), + ) + + response = await client.put( + "/api/default-model/fallbacks", + json={ + "fallback_providers": [ + {"provider_id": "openai", "model_id": "gpt-4o"} + ] + }, + ) + + assert response.status_code == expected_status + if expected_status == 400: + assert "provider 'openai' is disabled" in response.json()["message"] + fallback_route_stubs.set_fallback_providers.assert_not_called() + + +@pytest.mark.asyncio +async def test_put_empty_fallbacks_clears_configuration( + client: AsyncClient, + fallback_route_stubs: MagicMock, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setattr( + default_model_routes, + "get_model_manager", + lambda: _ModelManagerStub({}), + ) + + response = await client.put( + "/api/default-model/fallbacks", + json={"fallback_providers": []}, + ) + + assert response.status_code == 200 + assert response.json() == {"fallback_providers": []} + fallback_route_stubs.set_fallback_providers.assert_called_once_with([]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "models", "disabled", "detail"), + [ + ( + [{"provider_id": " ", "model_id": "gpt-4o"}], + {}, + set(), + "must include provider_id and model_id", + ), + ( + [ + {"provider_id": "openai", "model_id": "gpt-4o"}, + {"provider_id": " openai ", "model_id": " gpt-4o "}, + ], + {("openai", "gpt-4o"): _definition()}, + set(), + "Duplicate fallback model", + ), + ( + [{"provider_id": "anthropic", "model_id": "claude-primary"}], + {("anthropic", "claude-primary"): _definition()}, + set(), + "is the current default LLM", + ), + ( + [{"provider_id": "missing", "model_id": "missing-model"}], + {}, + set(), + "Unknown fallback model", + ), + ( + [{"provider_id": "openai", "model_id": "embedding-model"}], + { + ("openai", "embedding-model"): _definition( + ModelType.TEXT_EMBEDDING + ) + }, + set(), + "is not an LLM", + ), + ( + [{"provider_id": "openai", "model_id": "gpt-disabled"}], + {("openai", "gpt-disabled"): _definition()}, + {("openai", "gpt-disabled")}, + "is disabled", + ), + ], +) +async def test_put_fallbacks_rejects_invalid_entries( + client: AsyncClient, + fallback_route_stubs: MagicMock, + monkeypatch: pytest.MonkeyPatch, + payload, + models, + disabled, + detail, +): + monkeypatch.setattr( + default_model_routes, + "get_model_manager", + lambda: _ModelManagerStub(models, disabled), + ) + + response = await client.put( + "/api/default-model/fallbacks", + json={"fallback_providers": payload}, + ) + + assert response.status_code == 400 + assert detail in response.json()["message"] + fallback_route_stubs.set_fallback_providers.assert_not_called() diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 6cbfffeee..d0b89fbfb 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -14,7 +14,7 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException, status @@ -31,6 +31,16 @@ from flocks.session.orphan_tools import INTERRUPTED_TOOL_ERROR from flocks.session.session import Session + +def _use_webui_admin(monkeypatch: pytest.MonkeyPatch) -> AuthUser: + """Authenticate a route test as a browser user instead of the API token.""" + from flocks.server.routes import session as session_routes + + user = AuthUser(id="usr_admin", username="admin", role="admin", status="active") + monkeypatch.setattr(session_routes, "require_user", lambda _request: user) + return user + + # =========================================================================== # CRUD # =========================================================================== @@ -101,6 +111,97 @@ async def test_create_session_with_category(self, client: AsyncClient): assert resp.status_code == status.HTTP_200_OK assert resp.json()["category"] == "workflow" + @pytest.mark.asyncio + async def test_create_session_with_manual_auto_mode( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + """Auto is persisted only when explicitly requested by the WebUI.""" + from flocks.session.session_loop import SessionLoop + + _use_webui_admin(monkeypatch) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + AsyncMock(return_value=(True, "available")), + ) + resp = await client.post( + "/api/session", + json={"title": "Auto Session", "model_auto": True}, + ) + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["model_auto"] is True + assert resp.json()["model_pinned"] is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("category", ["workflow", "task"]) + async def test_create_non_user_session_rejects_auto( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + category: str, + ): + from flocks.session.session_loop import SessionLoop + + validate_auto = AsyncMock(return_value=(True, "available")) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + validate_auto, + ) + + resp = await client.post( + "/api/session", + json={"category": category, "model_auto": True}, + ) + + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert "only available for user sessions" in str(resp.json()) + validate_auto.assert_not_awaited() + + @pytest.mark.asyncio + async def test_create_session_rejects_unavailable_auto( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + _use_webui_admin(monkeypatch) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + AsyncMock(return_value=(False, "fallback_unavailable")), + ) + + resp = await client.post("/api/session", json={"model_auto": True}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "fallback_unavailable" in str(resp.json()) + + @pytest.mark.asyncio + async def test_api_token_cannot_create_auto_session( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + validate_auto = AsyncMock(return_value=(True, "available")) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + validate_auto, + ) + + resp = await client.post("/api/session", json={"model_auto": True}) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert "only be enabled from the WebUI" in str(resp.json()) + validate_auto.assert_not_awaited() + @pytest.mark.asyncio async def test_create_session_with_api_token_is_ownerless(self, client: AsyncClient): """Sessions created by API-token clients remain manageable by WebUI admins.""" @@ -281,6 +382,7 @@ async def test_list_sessions_light_manager_filters_and_omits_heavy_fields(self, "provider", "model", "model_pinned", + "model_auto", "canWrite", "canDelete", "isShared", @@ -541,6 +643,160 @@ async def test_update_session_title(self, client: AsyncClient, session_id: str): assert resp.status_code == status.HTTP_200_OK assert resp.json()["title"] == "Updated Title" + @pytest.mark.asyncio + async def test_update_session_auto_and_concrete_model_are_exclusive( + self, + client: AsyncClient, + session_id: str, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + _use_webui_admin(monkeypatch) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + AsyncMock(return_value=(True, "available")), + ) + auto_resp = await client.patch( + f"/api/session/{session_id}", + json={"model_auto": True}, + ) + assert auto_resp.status_code == status.HTTP_200_OK + assert auto_resp.json()["model_auto"] is True + assert auto_resp.json()["model_pinned"] is False + + invalid_resp = await client.patch( + f"/api/session/{session_id}", + json={ + "model_auto": True, + "provider": "openai", + "model": "gpt-4o", + "model_pinned": True, + }, + ) + assert invalid_resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + concrete_resp = await client.patch( + f"/api/session/{session_id}", + json={"provider": "openai", "model": "gpt-4o"}, + ) + assert concrete_resp.status_code == status.HTTP_200_OK + assert concrete_resp.json()["model_auto"] is False + assert concrete_resp.json()["model_pinned"] is True + + @pytest.mark.asyncio + async def test_update_session_rejects_unavailable_auto( + self, + client: AsyncClient, + session_id: str, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + _use_webui_admin(monkeypatch) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + AsyncMock(return_value=(False, "primary_provider_not_configured")), + ) + + resp = await client.patch( + f"/api/session/{session_id}", + json={"model_auto": True}, + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "primary_provider_not_configured" in str(resp.json()) + + @pytest.mark.asyncio + async def test_pinning_existing_auto_session_disables_auto( + self, + client: AsyncClient, + session_id: str, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + _use_webui_admin(monkeypatch) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + AsyncMock(return_value=(True, "available")), + ) + clear_state = MagicMock() + monkeypatch.setattr(SessionLoop, "clear_auto_failover_state", clear_state) + auto_resp = await client.patch( + f"/api/session/{session_id}", + json={"model_auto": True}, + ) + assert auto_resp.status_code == status.HTTP_200_OK + + pin_resp = await client.patch( + f"/api/session/{session_id}", + json={"model_pinned": True}, + ) + + assert pin_resp.status_code == status.HTTP_200_OK + assert pin_resp.json()["model_pinned"] is True + assert pin_resp.json()["model_auto"] is False + clear_state.assert_called_once_with(session_id) + + @pytest.mark.asyncio + async def test_api_token_cannot_enable_auto_on_existing_session( + self, + client: AsyncClient, + session_id: str, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + validate_auto = AsyncMock(return_value=(True, "available")) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + validate_auto, + ) + + resp = await client.patch( + f"/api/session/{session_id}", + json={"model_auto": True}, + ) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert "only be enabled from the WebUI" in str(resp.json()) + validate_auto.assert_not_awaited() + + @pytest.mark.asyncio + async def test_update_non_user_session_rejects_auto( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.session.session_loop import SessionLoop + + create_resp = await client.post( + "/api/session", + json={"title": "Workflow Session", "category": "workflow"}, + ) + assert create_resp.status_code == status.HTTP_200_OK + session_id = create_resp.json()["id"] + validate_auto = AsyncMock(return_value=(True, "available")) + monkeypatch.setattr( + SessionLoop, + "validate_auto_configuration", + validate_auto, + ) + + resp = await client.patch( + f"/api/session/{session_id}", + json={"model_auto": True}, + ) + + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert "only available for user sessions" in str(resp.json()) + validate_auto.assert_not_awaited() + @pytest.mark.asyncio async def test_update_session_not_found(self, client: AsyncClient): """PATCH for unknown session returns 404.""" @@ -1432,7 +1688,185 @@ async def test_prepare_replay_runtime_uses_current_model_resolution( "agent_name": "rex", "provider_id": "openai", "model_id": "gpt-4.1", + "auto_failover": False, + } + + @pytest.mark.asyncio + async def test_auto_replay_reports_actual_fallback_model( + self, + monkeypatch: pytest.MonkeyPatch, + ): + """Replay passes Auto authorization and publishes the recovered model.""" + from flocks.server.routes import session as session_routes + from flocks.session.session_loop import LoopResult, SessionLoop + + user_message = SimpleNamespace(id="msg_user", agent="rex") + session = SimpleNamespace(id="ses_auto", directory="/tmp/project") + assistant = SimpleNamespace( + id="msg_assistant", + providerID="fallback", + modelID="fallback-model", + finish="stop", + tokens=None, + time={"created": 123}, + ) + run = AsyncMock(return_value=LoopResult( + action="stop", + last_message=assistant, + provider_id="fallback", + model_id="fallback-model", + )) + publish = AsyncMock() + context_usage = AsyncMock() + + monkeypatch.setattr(SessionLoop, "run", run) + monkeypatch.setattr( + "flocks.session.lifecycle.revert.SessionRevert.cleanup", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.session.message.Message.get_text_content", + AsyncMock(return_value="recovered"), + ) + monkeypatch.setattr("flocks.server.routes.event.publish_event", publish) + monkeypatch.setattr( + session_routes, + "_publish_context_usage_update", + context_usage, + ) + + result = await session_routes._run_existing_user_message( + session.id, + session, + user_message, + session.directory, + runtime={ + "agent_name": "rex", + "provider_id": "primary", + "model_id": "primary-model", + "auto_failover": True, + }, + ) + + assert result["status"] == "completed" + assert run.await_args.kwargs["auto_failover"] is True + completion = next( + call.args[1] + for call in publish.await_args_list + if call.args[0] == "message.updated" + ) + assert completion["info"]["providerID"] == "fallback" + assert completion["info"]["modelID"] == "fallback-model" + assert context_usage.await_args.kwargs["provider_id"] == "fallback" + assert context_usage.await_args.kwargs["model_id"] == "fallback-model" + + @pytest.mark.asyncio + async def test_prepare_auto_replay_defers_unavailable_primary_to_failover( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.session_loop import SessionLoop + + user_message = SimpleNamespace(agent="rex") + monkeypatch.setattr( + "flocks.agent.registry.Agent.get", + AsyncMock(return_value=SimpleNamespace(name="rex", model=None)), + ) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=SimpleNamespace(model_auto=True, category="user")), + ) + monkeypatch.setattr( + "flocks.config.config.Config.resolve_default_llm", + AsyncMock(return_value={ + "provider_id": "primary", + "model_id": "primary-model", + }), + ) + monkeypatch.setattr( + "flocks.config.config.Config.get", + AsyncMock(return_value=SimpleNamespace()), + ) + validate = AsyncMock(return_value=(False, "provider_not_configured")) + monkeypatch.setattr(SessionLoop, "validate_runtime_model", validate) + monkeypatch.setattr("flocks.provider.provider.Provider._ensure_initialized", lambda: None) + monkeypatch.setattr("flocks.provider.provider.Provider.apply_config", AsyncMock()) + monkeypatch.setattr("flocks.provider.provider.Provider.get", lambda _provider_id: None) + resolve = AsyncMock() + monkeypatch.setattr(session_routes, "_resolve_model", resolve) + + runtime = await session_routes._prepare_replay_runtime("ses_auto", user_message) + + assert runtime == { + "agent_name": "rex", + "provider_id": "primary", + "model_id": "primary-model", + "auto_failover": True, + } + validate.assert_not_awaited() + resolve.assert_not_awaited() + + @pytest.mark.asyncio + async def test_prepare_replay_ignores_legacy_auto_on_non_user_session( + self, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + from flocks.session.session_loop import SessionLoop + + user_message = SimpleNamespace(agent="rex") + monkeypatch.setattr( + "flocks.agent.registry.Agent.get", + AsyncMock(return_value=SimpleNamespace(name="rex", model=None)), + ) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=SimpleNamespace( + model_auto=True, + category="workflow", + )), + ) + resolve = AsyncMock(return_value=("direct", "direct-model", "session")) + monkeypatch.setattr(session_routes, "_resolve_model", resolve) + default_llm = AsyncMock() + monkeypatch.setattr( + "flocks.config.config.Config.resolve_default_llm", + default_llm, + ) + monkeypatch.setattr( + "flocks.config.config.Config.get", + AsyncMock(return_value=SimpleNamespace()), + ) + validate = AsyncMock() + monkeypatch.setattr(SessionLoop, "validate_runtime_model", validate) + monkeypatch.setattr( + "flocks.provider.provider.Provider._ensure_initialized", + lambda: None, + ) + monkeypatch.setattr( + "flocks.provider.provider.Provider.apply_config", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.provider.provider.Provider.get", + lambda _provider_id: object(), + ) + + runtime = await session_routes._prepare_replay_runtime( + "ses_workflow", + user_message, + ) + + assert runtime == { + "agent_name": "rex", + "provider_id": "direct", + "model_id": "direct-model", + "auto_failover": False, } + resolve.assert_awaited_once() + default_llm.assert_not_awaited() + validate.assert_not_awaited() @pytest.mark.asyncio async def test_resend_uses_current_model_for_replay( diff --git a/tests/session/test_auto_model_failover.py b/tests/session/test_auto_model_failover.py new file mode 100644 index 000000000..8f883db27 --- /dev/null +++ b/tests/session/test_auto_model_failover.py @@ -0,0 +1,1005 @@ +"""Focused tests for WebUI Auto runtime model failover.""" + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from flocks.session.message import Message, MessageRole +from flocks.session.runner import ( + LlmAttemptState, + SessionRunner, + StepFailure, + StepResult, +) +from flocks.session.session import Session, SessionInfo +from flocks.session.session_loop import ( + AutoFailoverCooldown, + LoopCallbacks, + LoopContext, + LoopResult, + RuntimeModel, + SessionLoop, +) + + +def _session(**updates) -> SessionInfo: + values = { + "id": "ses_auto", + "projectID": "project", + "directory": "/tmp/project", + "agent": "rex", + "provider": "primary", + "model": "primary-model", + "model_pinned": False, + "model_auto": True, + } + values.update(updates) + return SessionInfo.model_construct(**values) + + +def _ctx(*, auto: bool = True, index: int = 0) -> LoopContext: + candidates = [ + RuntimeModel("primary", "primary-model"), + RuntimeModel("fallback", "fallback-model"), + ] + active = candidates[index] + return LoopContext( + session=_session(provider=active.provider_id, model=active.model_id), + provider_id=active.provider_id, + model_id=active.model_id, + agent_name="rex", + auto_failover=auto, + auto_failover_allowed=auto, + model_candidates=candidates if auto else [active], + candidate_index=index if auto else 0, + ) + + +def _failure( + *, + assistant_id: str, + reason: str = "server_error", + safe: bool = True, +) -> StepResult: + state = LlmAttemptState(observable_output_started=not safe) + message = "provider failed" + return StepResult( + action="stop", + error=message, + failure=StepFailure( + message=message, + error_data={"name": "APIError", "data": {"message": message}}, + assistant_message_id=assistant_id, + reason=reason, + allow_fallback=safe, + attempt_state=state, + attempts=1, + ), + ) + + +@pytest.fixture(autouse=True) +def _clear_cooldowns(): + SessionLoop._auto_failover_cooldowns.clear() + yield + SessionLoop._auto_failover_cooldowns.clear() + + +@pytest.mark.parametrize( + ("status_code", "message", "reason", "same_model_retries"), + [ + (401, "Unauthorized", "auth", 0), + (402, "Payment required", "billing", 0), + (429, "Too many requests", "rate_limit", 0), + (403, "Quota exceeded", "rate_limit", 0), + (403, "Insufficient quota", "billing", 0), + (408, "Request timeout", "timeout", 1), + (404, "Route not found", "unknown_api", 3), + (500, "Internal server error", "server_error", 3), + (502, "Bad gateway", "server_error", 3), + (529, "Provider overloaded", "overloaded", 1), + ], +) +def test_failover_classifier_retry_thresholds( + status_code: int, + message: str, + reason: str, + same_model_retries: int, +): + decision = SessionRunner.classify_failover_error({ + "name": "APIError", + "data": {"message": message, "statusCode": status_code}, + }) + + assert decision.eligible is True + assert decision.reason == reason + assert decision.same_model_retries == same_model_retries + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status_code", "expected_calls"), + [ + (401, 1), + (402, 1), + (429, 1), + (408, 2), + (404, 4), + (500, 4), + (502, 4), + (529, 2), + ], +) +async def test_runner_applies_failover_retry_thresholds( + monkeypatch, + status_code: int, + expected_calls: int, +): + runner = SessionRunner( + session=_session(), + provider_id="primary", + model_id="primary-model", + defer_step_errors=True, + failover_available=True, + ) + last_user = SimpleNamespace(id="msg_user", agent="rex", role="user") + provider = MagicMock() + provider.is_configured.return_value = True + assistant = SimpleNamespace(id="msg_assistant") + failure = RuntimeError(f"Provider HTTP {status_code}") + failure.status_code = status_code + call_llm = AsyncMock(side_effect=failure) + + monkeypatch.setattr( + "flocks.session.runner.Agent.get", + AsyncMock(return_value=SimpleNamespace( + name="rex", + steps=None, + mode="primary", + prompt="", + tools=[], + )), + ) + monkeypatch.setattr("flocks.session.runner.Provider.get", lambda _provider_id: provider) + monkeypatch.setattr("flocks.session.runner.Provider.apply_config", AsyncMock()) + monkeypatch.setattr( + "flocks.session.runner.SessionPrompt.build_system_prompts", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr(runner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr( + runner, + "_to_chat_messages", + AsyncMock(return_value=[SimpleNamespace(role="user", content="hello")]), + ) + monkeypatch.setattr(Message, "get_text_content", AsyncMock(return_value="hello")) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr(Message, "create", AsyncMock(return_value=assistant)) + monkeypatch.setattr(Message, "update", AsyncMock()) + monkeypatch.setattr(runner, "_call_llm", call_llm) + monkeypatch.setattr("flocks.session.runner.SessionRetry.sleep", AsyncMock()) + + result = await runner._process_step([last_user], last_user) + + assert result.failure is not None + assert call_llm.await_count == expected_calls + + +def test_local_validation_error_never_fails_over(): + decision = SessionRunner.classify_failover_error({ + "name": "ValidationError", + "data": {"message": "Local prompt schema validation failed"}, + }) + + assert decision.eligible is False + assert decision.reason == "local_error" + + +def test_model_not_found_without_status_fails_over(): + decision = SessionRunner.classify_failover_error({ + "name": "ValueError", + "data": {"message": "Model acme-v2 not found for provider custom"}, + }) + + assert decision.eligible is True + assert decision.reason == "model_not_found" + assert decision.same_model_retries == 0 + + +def test_candidate_switch_keeps_tool_loop_guard_only(): + ctx = _ctx() + tool_loop_guard = { + "last_user_id": "msg_user", + "signature": "same-tool-call", + "count": 2, + } + ctx.runner_static_cache.update({ + "tool_loop_guard": tool_loop_guard, + "tool_schema_cache": {"primary": "schema"}, + "chat_context_cache": {"primary": "context"}, + "system_prompt": "primary prompt", + }) + + SessionLoop._select_candidate(ctx, 1) + + assert ctx.runner_static_cache == {"tool_loop_guard": tool_loop_guard} + assert ctx.runner_static_cache["tool_loop_guard"] is tool_loop_guard + + +@pytest.mark.asyncio +async def test_reasoning_only_empty_response_is_not_replayed(monkeypatch): + runner = SessionRunner( + session=_session(), + provider_id="primary", + model_id="primary-model", + defer_step_errors=True, + failover_available=True, + ) + last_user = SimpleNamespace(id="msg_user", agent="rex", role="user") + provider = MagicMock() + provider.is_configured.return_value = True + assistant = SimpleNamespace(id="msg_assistant") + call_count = 0 + + async def call_llm(*_args, **_kwargs): + nonlocal call_count + call_count += 1 + runner._attempt_state.observable_output_started = True + return StepResult(action="stop", content="") + + monkeypatch.setattr( + "flocks.session.runner.Agent.get", + AsyncMock(return_value=SimpleNamespace( + name="rex", + steps=None, + mode="primary", + prompt="", + tools=[], + )), + ) + monkeypatch.setattr("flocks.session.runner.Provider.get", lambda _provider_id: provider) + monkeypatch.setattr("flocks.session.runner.Provider.apply_config", AsyncMock()) + monkeypatch.setattr( + "flocks.session.runner.SessionPrompt.build_system_prompts", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr(runner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr( + runner, + "_to_chat_messages", + AsyncMock(return_value=[SimpleNamespace(role="user", content="hello")]), + ) + monkeypatch.setattr(Message, "get_text_content", AsyncMock(return_value="hello")) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr(Message, "create", AsyncMock(return_value=assistant)) + monkeypatch.setattr(Message, "update", AsyncMock()) + monkeypatch.setattr(runner, "_call_llm", call_llm) + sleep = AsyncMock() + monkeypatch.setattr("flocks.session.runner.SessionRetry.sleep", sleep) + + result = await runner._process_step([last_user], last_user) + + assert call_count == 1 + assert result.failure is not None + assert result.failure.allow_fallback is False + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("chunk_kind", ["text", "reasoning", "tool"]) +async def test_real_stream_activity_prevents_retry_and_fallback( + monkeypatch, + chunk_kind: str, +): + """Exercise the real stream loop, then fail after an observable fragment.""" + + class FakeStreamProcessor: + def __init__(self, *args, tool_start_callback=None, **kwargs): + self.tool_start_callback = tool_start_callback + self.tool_calls = {} + self._text = [] + self._reasoning = [] + + async def process_event(self, event): + event_name = type(event).__name__ + if event_name == "TextDeltaEvent": + self._text.append(event.text) + elif event_name == "ReasoningDeltaEvent": + self._reasoning.append(event.text) + elif event_name == "ToolCallEvent": + if self.tool_start_callback: + await self.tool_start_callback(event.tool_name, event.input) + self.tool_calls[event.tool_call_id] = SimpleNamespace( + id=event.tool_call_id, + name=event.tool_name, + input=event.input, + ) + + def get_text_content(self): + return "".join(self._text) + + def get_reasoning_content(self): + return "".join(self._reasoning) + + if chunk_kind == "text": + chunk = SimpleNamespace( + delta="visible text", + reasoning=None, + event_type=None, + metadata={}, + tool_calls=None, + finish_reason=None, + usage=None, + ) + elif chunk_kind == "reasoning": + chunk = SimpleNamespace( + delta="visible reasoning", + reasoning=None, + event_type="reasoning", + metadata={}, + tool_calls=None, + finish_reason=None, + usage=None, + ) + else: + chunk = SimpleNamespace( + delta="", + reasoning=None, + event_type=None, + metadata={}, + tool_calls=[{ + "index": 0, + "id": "call_1", + "function": {"name": "example_tool", "arguments": "{}"}, + }], + finish_reason=None, + usage=None, + ) + + class FailingStreamProvider: + def __init__(self): + self.calls = 0 + + def is_configured(self): + return True + + def chat_stream(self, **_kwargs): + self.calls += 1 + + async def stream(): + yield chunk + failure = RuntimeError("Provider HTTP 500") + failure.status_code = 500 + raise failure + + return stream() + + provider = FailingStreamProvider() + runner = SessionRunner( + session=_session(), + provider_id="primary", + model_id="primary-model", + defer_step_errors=True, + failover_available=True, + ) + last_user = SimpleNamespace(id="msg_user", agent="rex", role="user") + assistant = SimpleNamespace(id="msg_assistant") + + monkeypatch.setattr( + "flocks.session.runner.Agent.get", + AsyncMock(return_value=SimpleNamespace( + name="rex", + steps=None, + mode="primary", + prompt="", + tools=[], + )), + ) + monkeypatch.setattr("flocks.session.runner.Provider.get", lambda _provider_id: provider) + monkeypatch.setattr("flocks.session.runner.Provider.apply_config", AsyncMock()) + monkeypatch.setattr( + "flocks.session.runner.SessionPrompt.build_system_prompts", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr(runner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr( + runner, + "_to_chat_messages", + AsyncMock(return_value=[SimpleNamespace(role="user", content="hello")]), + ) + monkeypatch.setattr(runner, "_should_use_text_tool_call_mode", lambda: False) + monkeypatch.setattr(Message, "get_text_content", AsyncMock(return_value="hello")) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr(Message, "create", AsyncMock(return_value=assistant)) + monkeypatch.setattr(Message, "update", AsyncMock()) + monkeypatch.setattr("flocks.session.runner.StreamProcessor", FakeStreamProcessor) + monkeypatch.setattr( + "flocks.session.runner.HookPipeline.has_stage_handlers", + AsyncMock(return_value=False), + ) + monkeypatch.setattr("flocks.session.runner.langfuse_is_active", lambda: False) + monkeypatch.setattr( + "flocks.provider.options.build_provider_options", + lambda _provider_id, _model_id: {}, + ) + sleep = AsyncMock() + monkeypatch.setattr("flocks.session.runner.SessionRetry.sleep", sleep) + + result = await runner._process_step([last_user], last_user) + + assert provider.calls == 1 + assert result.failure is not None + assert result.failure.allow_fallback is False + assert result.failure.attempt_state.received_chunk is True + assert result.failure.attempt_state.observable_output_started is True + assert result.failure.attempt_state.tool_execution_started is ( + chunk_kind == "tool" + ) + sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_safe_failure_switches_candidate_and_removes_blank_message(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + events = [] + + async def process_step(runner, _messages, _last_user): + if runner.provider_id == "primary": + return _failure(assistant_id="msg_failed") + return StepResult(action="stop", content="recovered") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + delete = AsyncMock(return_value=True) + monkeypatch.setattr(Message, "delete", delete) + + async def publish(event, payload): + events.append((event, payload)) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(event_publish_callback=publish), + [last_user], + last_user, + ) + + assert result.content == "recovered" + assert (ctx.provider_id, ctx.model_id) == ("fallback", "fallback-model") + delete.assert_awaited_once_with("ses_auto", "msg_failed") + assert any(event == "message.removed" for event, _ in events) + assert any(event == "session.model.fallback" for event, _ in events) + + +@pytest.mark.asyncio +async def test_failed_blank_message_deletion_stops_switch(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + monkeypatch.setattr( + SessionRunner, + "_process_step", + AsyncMock(return_value=_failure(assistant_id="msg_failed")), + ) + monkeypatch.setattr(Message, "delete", AsyncMock(return_value=False)) + update = AsyncMock() + monkeypatch.setattr(Message, "update", update) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + assert result.error == "provider failed" + assert ctx.provider_id == "primary" + update.assert_awaited_once_with( + "ses_auto", + "msg_failed", + error={"name": "APIError", "data": {"message": "provider failed"}}, + finish="error", + ) + + +@pytest.mark.asyncio +async def test_fallbacks_are_attempted_in_configured_order(monkeypatch): + ctx = _ctx() + ctx.model_candidates = [ + RuntimeModel("primary", "primary-model"), + RuntimeModel("fallback-1", "model-1"), + RuntimeModel("fallback-2", "model-2"), + ] + last_user = SimpleNamespace(id="msg_user", agent="rex") + attempts = [] + + async def process_step(runner, _messages, _last_user): + attempts.append((runner.provider_id, runner.model_id)) + if runner.provider_id != "fallback-2": + return _failure(assistant_id=f"msg_{runner.provider_id}") + return StepResult(action="stop", content="recovered") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + delete = AsyncMock(return_value=True) + monkeypatch.setattr(Message, "delete", delete) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + assert result.content == "recovered" + assert attempts == [ + ("primary", "primary-model"), + ("fallback-1", "model-1"), + ("fallback-2", "model-2"), + ] + assert delete.await_count == 2 + + +@pytest.mark.asyncio +async def test_chain_exhaustion_finalizes_only_last_candidate(monkeypatch): + ctx = _ctx() + ctx.model_candidates = [ + RuntimeModel("primary", "primary-model"), + RuntimeModel("fallback-1", "model-1"), + RuntimeModel("fallback-2", "model-2"), + ] + last_user = SimpleNamespace(id="msg_user", agent="rex") + + async def process_step(runner, _messages, _last_user): + return _failure(assistant_id=f"msg_{runner.provider_id}") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + delete = AsyncMock(return_value=True) + update = AsyncMock() + monkeypatch.setattr(Message, "delete", delete) + monkeypatch.setattr(Message, "update", update) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + assert result.error == "provider failed" + assert delete.await_count == 2 + update.assert_awaited_once() + assert update.await_args.args[1] == "msg_fallback-2" + cooldown = SessionLoop._auto_failover_cooldowns[ctx.session.id] + assert cooldown.model == RuntimeModel("fallback-2", "model-2") + assert cooldown.reason == "chain_exhausted" + + +@pytest.mark.asyncio +async def test_full_loop_reports_chain_exhaustion_once(monkeypatch): + ctx = _ctx() + ctx.session.memory_enabled = False + user = SimpleNamespace( + id="msg_user", + role=MessageRole.USER, + agent="rex", + model={"providerID": "primary", "modelID": "primary-model"}, + ) + final_assistant = SimpleNamespace( + id="msg_fallback", + role=MessageRole.ASSISTANT, + parentID=user.id, + finish="error", + ) + ctx.session_ctx = SimpleNamespace( + get_messages=AsyncMock(side_effect=[ + [user], + [user, final_assistant], + ]) + ) + attempts = [] + + async def process_step(runner, _messages, _last_user): + attempts.append((runner.provider_id, runner.model_id)) + return _failure(assistant_id=f"msg_{runner.provider_id}") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr(Message, "delete", AsyncMock(return_value=True)) + update = AsyncMock() + monkeypatch.setattr(Message, "update", update) + on_error = AsyncMock() + + result = await SessionLoop._run_loop( + ctx, + LoopCallbacks( + on_error=on_error, + event_publish_callback=AsyncMock(), + ), + ) + + assert result.action == "error" + assert result.error == "provider failed" + assert result.last_message is final_assistant + assert (result.provider_id, result.model_id) == ( + "fallback", + "fallback-model", + ) + assert attempts == [ + ("primary", "primary-model"), + ("fallback", "fallback-model"), + ] + on_error.assert_awaited_once_with("provider failed") + update.assert_awaited_once() + assert update.await_args.args[1] == "msg_fallback" + + +@pytest.mark.asyncio +async def test_observable_failure_is_finalized_without_replay(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + monkeypatch.setattr( + SessionRunner, + "_process_step", + AsyncMock(return_value=_failure(assistant_id="msg_partial", safe=False)), + ) + delete = AsyncMock(return_value=True) + update = AsyncMock() + monkeypatch.setattr(Message, "delete", delete) + monkeypatch.setattr(Message, "update", update) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + assert result.error == "provider failed" + assert ctx.provider_id == "primary" + delete.assert_not_awaited() + update.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_rate_limit_switch_sets_primary_cooldown(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + + async def process_step(runner, _messages, _last_user): + if runner.provider_id == "primary": + return _failure(assistant_id="msg_rate", reason="rate_limit") + return StepResult(action="stop", content="recovered") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + monkeypatch.setattr(Message, "delete", AsyncMock(return_value=True)) + + await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + cooldown = SessionLoop._auto_failover_cooldowns[ctx.session.id] + assert cooldown.model == RuntimeModel("fallback", "fallback-model") + assert cooldown.reason == "rate_limit" + assert SessionLoop._cooldown_candidate_index( + ctx.session.id, + ctx.model_candidates, + ) == 1 + + +@pytest.mark.asyncio +async def test_403_quota_failure_sets_primary_cooldown(monkeypatch): + decision = SessionRunner.classify_failover_error({ + "name": "APIError", + "data": {"message": "Quota exceeded", "statusCode": 403}, + }) + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + + async def process_step(runner, _messages, _last_user): + if runner.provider_id == "primary": + return _failure( + assistant_id="msg_quota", + reason=decision.reason, + ) + return StepResult(action="stop", content="recovered") + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + monkeypatch.setattr(Message, "delete", AsyncMock(return_value=True)) + + await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + cooldown = SessionLoop._auto_failover_cooldowns[ctx.session.id] + assert cooldown.reason == "rate_limit" + assert cooldown.model == RuntimeModel("fallback", "fallback-model") + assert cooldown.expires_at > time.monotonic() + 50 + + +@pytest.mark.asyncio +async def test_chain_exhaustion_does_not_shorten_rate_limit_cooldown(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace(id="msg_user", agent="rex") + + async def process_step(runner, _messages, _last_user): + reason = "rate_limit" if runner.provider_id == "primary" else "server_error" + return _failure( + assistant_id=f"msg_{runner.provider_id}", + reason=reason, + ) + + monkeypatch.setattr(SessionRunner, "_process_step", process_step) + monkeypatch.setattr(Message, "delete", AsyncMock(return_value=True)) + monkeypatch.setattr(Message, "update", AsyncMock()) + + await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + cooldown = SessionLoop._auto_failover_cooldowns[ctx.session.id] + assert cooldown.reason == "rate_limit" + assert cooldown.model == RuntimeModel("fallback", "fallback-model") + # A 5s anti-replay window must not replace the primary's 60s cooldown. + assert cooldown.expires_at > time.monotonic() + 50 + + +@pytest.mark.asyncio +async def test_candidate_builder_skips_unavailable_entries_in_order(monkeypatch): + from flocks.config.config import ConfigInfo + + monkeypatch.setattr( + "flocks.config.config.Config.get", + AsyncMock(return_value=ConfigInfo.model_validate({ + "fallback_providers": [ + {"provider_id": "missing", "model_id": "missing-model"}, + {"provider_id": "fallback-1", "model_id": "model-1"}, + {"provider_id": "fallback-2", "model_id": "model-2"}, + ], + })), + ) + # Runtime must consume Config.get() after all config sources are merged, + # rather than re-reading the raw file and ignoring env/content overrides. + monkeypatch.setattr( + "flocks.config.config_writer.ConfigWriter.get_fallback_providers", + lambda: [{"provider_id": "raw-file", "model_id": "ignored-model"}], + ) + + async def validate(provider_id, _model_id, **_kwargs): + return (provider_id != "missing", "available" if provider_id != "missing" else "disabled") + + monkeypatch.setattr(SessionLoop, "validate_runtime_model", validate) + + candidates = await SessionLoop._build_model_candidates( + RuntimeModel("primary", "primary-model") + ) + + assert candidates == [ + RuntimeModel("primary", "primary-model"), + RuntimeModel("fallback-1", "model-1"), + RuntimeModel("fallback-2", "model-2"), + ] + + +def test_cooldown_is_cleared_when_primary_changes(): + candidates = [ + RuntimeModel("new-primary", "new-model"), + RuntimeModel("fallback", "fallback-model"), + ] + SessionLoop._auto_failover_cooldowns["ses_auto"] = AutoFailoverCooldown( + model=RuntimeModel("fallback", "fallback-model"), + primary=RuntimeModel("old-primary", "old-model"), + expires_at=float("inf"), + reason="rate_limit", + ) + + assert SessionLoop._cooldown_candidate_index("ses_auto", candidates) == 0 + assert "ses_auto" not in SessionLoop._auto_failover_cooldowns + + +@pytest.mark.asyncio +async def test_synthetic_subtask_continuation_keeps_fallback(monkeypatch): + ctx = _ctx(index=1) + ctx.turn_user_id = "msg_real" + synthetic_user = SimpleNamespace( + id="msg_subtask_continue", + model={"providerID": "primary", "modelID": "primary-model"}, + ) + monkeypatch.setattr( + Message, + "parts", + AsyncMock(return_value=[SimpleNamespace(synthetic=True)]), + ) + + await SessionLoop._prepare_auto_turn(ctx, synthetic_user) + + assert ctx.auto_failover is True + assert ctx.turn_user_id == "msg_real" + assert (ctx.provider_id, ctx.model_id) == ("fallback", "fallback-model") + + +@pytest.mark.asyncio +async def test_queued_explicit_model_disables_auto(monkeypatch): + ctx = _ctx(index=1) + ctx.turn_user_id = "msg_real" + queued_user = SimpleNamespace( + id="msg_explicit", + model={"providerID": "explicit", "modelID": "explicit-model"}, + ) + persisted = _session( + provider="explicit", + model="explicit-model", + model_pinned=True, + model_auto=False, + ) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=persisted), + ) + + await SessionLoop._prepare_auto_turn(ctx, queued_user) + + assert ctx.auto_failover is False + assert ctx.model_candidates == [RuntimeModel("explicit", "explicit-model")] + assert (ctx.provider_id, ctx.model_id) == ("explicit", "explicit-model") + + +@pytest.mark.asyncio +async def test_non_webui_loop_cannot_activate_persisted_auto(monkeypatch): + ctx = _ctx(auto=False) + ctx.turn_user_id = "msg_real" + ctx.auto_failover_allowed = False + queued_user = SimpleNamespace( + id="msg_non_webui", + model={"providerID": "direct", "modelID": "direct-model"}, + ) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=_session(model_auto=True)), + ) + + await SessionLoop._prepare_auto_turn(ctx, queued_user) + + assert ctx.auto_failover is False + assert ctx.auto_failover_allowed is False + assert ctx.model_candidates == [RuntimeModel("direct", "direct-model")] + + +@pytest.mark.asyncio +async def test_queued_webui_turn_rebuilds_auto_chain(monkeypatch): + ctx = _ctx(auto=False) + ctx.turn_user_id = "msg_real" + ctx.auto_failover_allowed = True + queued_user = SimpleNamespace( + id="msg_auto", + model={"providerID": "primary", "modelID": "primary-model"}, + ) + rebuilt = [ + RuntimeModel("primary", "primary-model"), + RuntimeModel("new-fallback", "new-fallback-model"), + ] + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=_session(model_auto=True)), + ) + monkeypatch.setattr( + "flocks.config.config.Config.resolve_default_llm", + AsyncMock(return_value={ + "provider_id": "primary", + "model_id": "primary-model", + }), + ) + build = AsyncMock(return_value=rebuilt) + monkeypatch.setattr(SessionLoop, "_build_model_candidates", build) + + await SessionLoop._prepare_auto_turn(ctx, queued_user) + + assert ctx.auto_failover is True + assert ctx.model_candidates == rebuilt + build.assert_awaited_once_with(RuntimeModel("primary", "primary-model")) + + +@pytest.mark.asyncio +async def test_queued_webui_auto_authorizes_active_loop(): + ctx = _ctx(auto=False) + SessionLoop._active_loops[ctx.session.id] = ctx + try: + result = await SessionLoop.run(ctx.session.id, auto_failover=True) + finally: + SessionLoop._active_loops.pop(ctx.session.id, None) + + assert result.action == "queued" + assert ctx.auto_failover_allowed is True + + +@pytest.mark.asyncio +async def test_non_user_session_loop_ignores_auto_authorization(monkeypatch): + task_session = _session(category="task") + captured_ctx = None + + async def run_loop(ctx, _callbacks): + nonlocal captured_ctx + captured_ctx = ctx + return LoopResult(action="stop") + + build_candidates = AsyncMock() + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock(return_value=task_session), + ) + monkeypatch.setattr(SessionLoop, "_build_model_candidates", build_candidates) + monkeypatch.setattr(SessionLoop, "_run_loop", run_loop) + monkeypatch.setattr(SessionLoop, "_publish_session_status", AsyncMock()) + monkeypatch.setattr(Message, "list", AsyncMock(return_value=[])) + monkeypatch.setattr( + "flocks.session.orphan_tools.abort_orphan_running_parts", + AsyncMock(), + ) + monkeypatch.setattr( + "flocks.session.session.Session.touch", + AsyncMock(), + ) + monkeypatch.setattr("flocks.bus.bus.Bus.publish", AsyncMock()) + + await SessionLoop.run( + task_session.id, + provider_id="primary", + model_id="primary-model", + auto_failover=True, + ) + + assert captured_ctx is not None + assert captured_ctx.auto_failover is False + assert captured_ctx.auto_failover_allowed is False + assert captured_ctx.model_candidates == [ + RuntimeModel("primary", "primary-model") + ] + build_candidates.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_active_non_user_loop_rejects_auto_authorization(): + ctx = _ctx(auto=False) + ctx.session.category = "workflow" + SessionLoop._active_loops[ctx.session.id] = ctx + try: + result = await SessionLoop.run(ctx.session.id, auto_failover=True) + finally: + SessionLoop._active_loops.pop(ctx.session.id, None) + + assert result.action == "queued" + assert ctx.auto_failover_allowed is False + + +@pytest.mark.asyncio +async def test_session_delete_clears_auto_failover_cooldown(monkeypatch): + session = _session() + SessionLoop._auto_failover_cooldowns[session.id] = AutoFailoverCooldown( + model=RuntimeModel("fallback", "fallback-model"), + primary=RuntimeModel("primary", "primary-model"), + expires_at=float("inf"), + reason="rate_limit", + ) + monkeypatch.setattr(Session, "get", AsyncMock(return_value=session)) + monkeypatch.setattr(Session, "children", AsyncMock(return_value=[])) + monkeypatch.setattr(Session, "update", AsyncMock(return_value=session)) + monkeypatch.setattr(Message, "clear", AsyncMock(return_value=0)) + monkeypatch.setattr( + "flocks.session.callable_state.clear_session_callable_tools", + AsyncMock(), + ) + monkeypatch.setattr("flocks.bus.bus.Bus.publish", AsyncMock()) + + assert await Session.delete("project", session.id) is True + assert session.id not in SessionLoop._auto_failover_cooldowns diff --git a/tests/session/test_message_parts_persistence.py b/tests/session/test_message_parts_persistence.py index 4cb058253..61dafc1ff 100644 --- a/tests/session/test_message_parts_persistence.py +++ b/tests/session/test_message_parts_persistence.py @@ -1,6 +1,7 @@ """Persistence tests for message parts storage formats.""" import asyncio +from unittest.mock import AsyncMock import pytest @@ -180,6 +181,61 @@ async def test_delete_removes_parts_using_session_storage_format() -> None: assert await Storage.get(f"message_parts:{per_message_session_id}") is None +@pytest.mark.asyncio +async def test_delete_restores_caches_when_message_persistence_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = "ses_parts_delete_message_failure" + await Message.create( + session_id, + MessageRole.USER, + "keep me", + id="msg_a", + part_id="part_a", + ) + persist_messages = AsyncMock( + side_effect=RuntimeError("message storage unavailable") + ) + monkeypatch.setattr(Message, "_persist_messages", persist_messages) + + with pytest.raises(RuntimeError, match="message storage unavailable"): + await Message.delete(session_id, "msg_a") + + restored = await Message.get_with_parts_lazy(session_id, "msg_a") + assert restored is not None + assert restored.info.id == "msg_a" + assert [part.text for part in restored.parts] == ["keep me"] + stored_messages = await Storage.get(f"message:{session_id}") + assert [message["id"] for message in stored_messages] == ["msg_a"] + + +@pytest.mark.asyncio +async def test_delete_commits_when_parts_cleanup_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session_id = "ses_parts_delete_parts_failure" + await Message.create( + session_id, + MessageRole.USER, + "delete me", + id="msg_a", + part_id="part_a", + ) + original_delete = Storage.delete + + async def fail_parts_delete(key: str) -> None: + if key == f"message_parts:{session_id}:msg_a": + raise RuntimeError("parts storage unavailable") + await original_delete(key) + + monkeypatch.setattr(Storage, "delete", fail_parts_delete) + + assert await Message.delete(session_id, "msg_a") is True + assert await Message.get(session_id, "msg_a") is None + assert await Storage.get(f"message:{session_id}") == [] + assert await Storage.get(f"message_parts:{session_id}:msg_a") is not None + + @pytest.mark.asyncio async def test_clear_removes_legacy_blob_and_per_message_keys() -> None: legacy_session_id = "ses_parts_clear_legacy" diff --git a/tui/flocks/config/config.ts b/tui/flocks/config/config.ts index 12e9e5e55..5085330ed 100644 --- a/tui/flocks/config/config.ts +++ b/tui/flocks/config/config.ts @@ -931,6 +931,17 @@ export namespace Config { .string() .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), + fallback_providers: z + .array( + z + .object({ + provider_id: z.string(), + model_id: z.string(), + }) + .strict(), + ) + .optional() + .describe("Ordered fallback models used by WebUI Auto mode after the primary model fails"), default_agent: z .string() .optional() diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index e0f3062ef..ae633db8e 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -5,6 +5,7 @@ import type { ProviderInfoV2, ModelDefinitionV2, DefaultModelConfig, + FallbackModelsConfig, UsageStats, CustomProviderCreate, CustomProviderInfo, @@ -249,6 +250,16 @@ export const defaultModelAPI = { /** Delete default model for a type */ delete: (modelType: string) => client.delete(`/api/default-model/${modelType}`), + + /** Get the ordered runtime fallback chain used by WebUI Auto sessions. */ + getFallbacks: () => + client.get('/api/default-model/fallbacks'), + + /** Replace the ordered runtime fallback chain. An empty list clears it. */ + setFallbacks: (fallbackProviders: FallbackModelsConfig['fallback_providers']) => + client.put('/api/default-model/fallbacks', { + fallback_providers: fallbackProviders, + }), }; // ==================== Usage API ==================== diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index a421cb219..a034889b6 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -126,7 +126,7 @@ export const sessionApi = { /** * 创建会话 */ - create: async (data?: { title?: string; parentID?: string; projectID?: string }) => { + create: async (data?: { title?: string; parentID?: string; projectID?: string; model_auto?: boolean }) => { const response = await client.post('/api/session', data || {}); return response.data; }, @@ -142,7 +142,13 @@ export const sessionApi = { /** * 更新会话 */ - update: async (sessionId: string, data: { title?: string; provider?: string; model?: string; model_pinned?: boolean }) => { + update: async (sessionId: string, data: { + title?: string; + provider?: string; + model?: string; + model_pinned?: boolean; + model_auto?: boolean; + }) => { const response = await client.patch(`/api/session/${sessionId}`, data); return response.data; }, diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index de716c8fd..bb9d0f4d6 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -255,6 +255,7 @@ beforeEach(() => { addMessage: vi.fn(), updateMessage: vi.fn(), updateMessagePart: vi.fn(), + removeMessage: vi.fn(), replaceMessageText: vi.fn(), truncateAfterMessage: vi.fn(), }); @@ -306,6 +307,9 @@ function mockStatefulSessionMessages() { addMessage: (message: Message) => setMessages((prev) => [...prev, message]), updateMessage: upsertMessage, updateMessagePart: vi.fn(), + removeMessage: (messageId: string) => setMessages((prev) => prev.filter( + (message) => message.id !== messageId, + )), replaceMessageText: vi.fn(), markMessageStopped: (messageId: string) => setMessages((prev) => prev.map( (message) => (message.id === messageId ? { ...message, finish: 'stop' } : message), @@ -882,6 +886,36 @@ describe('SessionChat standalone thinking indicator', () => { consoleError.mockRestore(); } }); + + it('removes an intermediate assistant when message.removed arrives', () => { + const removeMessage = vi.fn(); + useSessionMessagesMock.mockReturnValue({ + messages: [makeMessage({ id: 'assistant-failed', role: 'assistant', parts: [] })], + loading: false, + refetch: vi.fn(), + addMessage: vi.fn(), + updateMessage: vi.fn(), + updateMessagePart: vi.fn(), + removeMessage, + replaceMessageText: vi.fn(), + markMessageStopped: vi.fn(), + truncateAfterMessage: vi.fn(), + }); + + render(React.createElement(SessionChat, { sessionId: 'sess-1' })); + + act(() => { + useSSEOptionsRef.current.onEvent({ + type: 'message.removed', + properties: { + sessionID: 'sess-1', + messageID: 'assistant-failed', + }, + }); + }); + + expect(removeMessage).toHaveBeenCalledWith('assistant-failed'); + }); }); describe('SessionChat instruction display text', () => { diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index c32b35d26..29df9c903 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -1691,6 +1691,7 @@ export default function SessionChat({ addMessage, updateMessage, updateMessagePart, + removeMessage, replaceMessageText, markMessageStopped, truncateAfterMessage, @@ -1853,6 +1854,17 @@ export default function SessionChat({ } return; } + case 'message-removed': { + const removedMessage = messagesRef.current.find((message) => message.id === action.messageID); + removedMessage?.parts.forEach((part) => { + if (part.id) activeToolPartIdsRef.current.delete(part.id); + }); + if (abortedMessageIdRef.current === action.messageID) { + abortedMessageIdRef.current = null; + } + removeMessage(action.messageID); + return; + } case 'message-part-updated': { const part = action.part as Pick; if (part.id) { @@ -1930,6 +1942,7 @@ export default function SessionChat({ sessionId, updateMessage, updateMessagePart, + removeMessage, refetch, refreshContextUsage, applyContextUsagePushSnapshot, diff --git a/webui/src/features/session-chat/sseActions.test.ts b/webui/src/features/session-chat/sseActions.test.ts index ca2874ac8..2213a5c45 100644 --- a/webui/src/features/session-chat/sseActions.test.ts +++ b/webui/src/features/session-chat/sseActions.test.ts @@ -58,6 +58,17 @@ describe('resolveSessionChatSSEAction', () => { info: { id: 'msg-1' }, }); + expect(resolveSessionChatSSEAction({ + type: 'message.removed', + properties: { + sessionID: 'sess-1', + messageID: 'msg-1', + }, + }, 'sess-1')).toEqual({ + kind: 'message-removed', + messageID: 'msg-1', + }); + expect(resolveSessionChatSSEAction({ type: 'message.part.updated', properties: { @@ -75,6 +86,14 @@ describe('resolveSessionChatSSEAction', () => { part: { id: 'part-1' }, delta: 'hello', }); + + expect(resolveSessionChatSSEAction({ + type: 'message.removed', + properties: { + sessionID: 'other-session', + messageID: 'msg-1', + }, + }, 'sess-1')).toEqual({ kind: 'ignore' }); }); it('resolves questions only when request and call ids are present', () => { diff --git a/webui/src/features/session-chat/sseActions.ts b/webui/src/features/session-chat/sseActions.ts index 58d5f3172..70fc3338d 100644 --- a/webui/src/features/session-chat/sseActions.ts +++ b/webui/src/features/session-chat/sseActions.ts @@ -24,6 +24,7 @@ export type SessionChatSSEAction = | { kind: 'session-cleared' } | { kind: 'session-status'; statusType?: string; message?: string } | { kind: 'message-updated'; info: SessionMessageInfo } + | { kind: 'message-removed'; messageID: string } | { kind: 'message-part-updated'; part: MessagePart; delta?: string } | { kind: 'question-asked'; callID: string; requestId: string; questions: unknown[] } | { kind: 'question-resolved'; requestId: string } @@ -82,6 +83,13 @@ export function resolveSessionChatSSEAction( return { kind: 'message-updated', info: properties.info as SessionMessageInfo }; } + if (type === 'message.removed') { + if (!isCurrentSession(properties, sessionId) || typeof properties.messageID !== 'string') { + return { kind: 'ignore' }; + } + return { kind: 'message-removed', messageID: properties.messageID }; + } + if (type === 'message.part.updated') { if (!isRecord(properties.part) || properties.part.sessionID !== sessionId) return { kind: 'ignore' }; return { diff --git a/webui/src/features/session-chat/sseRouting.test.ts b/webui/src/features/session-chat/sseRouting.test.ts index a95681412..4c899396e 100644 --- a/webui/src/features/session-chat/sseRouting.test.ts +++ b/webui/src/features/session-chat/sseRouting.test.ts @@ -27,6 +27,10 @@ describe('shouldForwardSSEEventToParent', () => { type: 'message.part.updated', properties: { part: { sessionID: 'session-1' } }, }, 'session-1')).toBe(true); + expect(shouldForwardSSEEventToParent({ + type: 'message.removed', + properties: { sessionID: 'session-1', messageID: 'message-1' }, + }, 'session-1')).toBe(true); expect(shouldForwardSSEEventToParent({ type: 'session.status', properties: { sessionID: 'session-1' }, diff --git a/webui/src/hooks/useChatModelResources.ts b/webui/src/hooks/useChatModelResources.ts index cd93f25af..8442ff46f 100644 --- a/webui/src/hooks/useChatModelResources.ts +++ b/webui/src/hooks/useChatModelResources.ts @@ -1,5 +1,5 @@ import { defaultModelAPI, modelV2API } from '@/api/provider'; -import type { ModelDefinitionV2 } from '@/types'; +import type { FallbackModelRef, ModelDefinitionV2 } from '@/types'; import { createSharedResource, useSharedResource, @@ -37,6 +37,17 @@ const resolvedDefaultModelResource = createSharedResource( fallbackDataOnError: null, }); +const fallbackModelsResource = createSharedResource({ + initialData: [], + staleTimeMs: CHAT_MODEL_RESOURCES_STALE_TIME_MS, + minFetchIntervalMs: 1000, + fetcher: async () => { + const response = await defaultModelAPI.getFallbacks(); + return response?.data?.fallback_providers ?? []; + }, + fallbackDataOnError: [], +}); + export function useEnabledChatModelDefinitions() { return useSharedResource(enabledModelDefinitionsResource); } @@ -49,6 +60,12 @@ export function useResolvedDefaultModel(enabled: boolean) { }); } +export function useFallbackModels() { + return useSharedResource(fallbackModelsResource, { + silentInitialLoad: true, + }); +} + export function fetchEnabledChatModelDefinitions( options?: SharedResourceFetchOptions, ): Promise { @@ -61,7 +78,12 @@ export function fetchResolvedDefaultModel( return resolvedDefaultModelResource.fetch(options); } +export function invalidateFallbackModels(): void { + fallbackModelsResource.invalidate(); +} + export function __resetChatModelResourcesForTesting(): void { enabledModelDefinitionsResource.resetForTesting(); resolvedDefaultModelResource.resetForTesting(); + fallbackModelsResource.resetForTesting(); } diff --git a/webui/src/hooks/useSessions.test.ts b/webui/src/hooks/useSessions.test.ts index 499489c6a..c99d269c7 100644 --- a/webui/src/hooks/useSessions.test.ts +++ b/webui/src/hooks/useSessions.test.ts @@ -260,6 +260,22 @@ describe('updateMessagePart scheduling', () => { expect((msg!.parts as any[])[0].text).toBe('hello world'); }); + it('removes a failed assistant placeholder by message id', async () => { + const { result } = renderHook(() => useSessionMessages('sess-1')); + await act(async () => {}); + + await act(async () => { + result.current.addMessage(makeMsg({ id: 'keep' })); + result.current.addMessage(makeMsg({ id: 'remove-me' })); + }); + + await act(async () => { + result.current.removeMessage('remove-me'); + }); + + expect(result.current.messages.map(message => message.id)).toEqual(['keep']); + }); + it('applies every known part update without waiting for an animation frame', async () => { const { result } = renderHook(() => useSessionMessages('sess-1')); await act(async () => {}); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 519aa382a..a2adb64d4 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -677,6 +677,9 @@ export function useSessionMessages(sessionId?: string) { updateMessagePart: (partInfo: any, delta?: string) => { setMessages(prev => applyMessagePartUpdate(prev, partInfo, delta)); }, + removeMessage: (messageId: string) => { + setMessages(prev => prev.filter((message) => message.id !== messageId)); + }, replaceMessageText: (messageId: string, partId: string, text: string) => { setMessages(prev => prev.map((message) => { if (message.id !== messageId) return message; diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index fbc54f05e..34c782cb3 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -9,6 +9,10 @@ "setDefaultModel": "Set Default Model", "defaultModelUpdated": "Default model updated", "defaultModelInvalid": "Default model \"{{model}}\" is no longer available and has been cleared. Please select a new one.", + "fallbackModels": "Fallback Models", + "noFallbackModels": "Not set", + "fallbackAvailability": "{{available}} / {{total}} available", + "editFallbackModels": "Edit fallback models", "connected": "Connected Provider", "availableModels": "Available Models", "totalUsage": "Total Usage", @@ -18,6 +22,23 @@ "noCost": "No cost", "toggleCurrency": "Click to switch between USD and CNY" }, + "fallbacks": { + "title": "Fallback Models", + "description": "Auto sessions try these models in order when the primary model fails.", + "empty": "No fallback models configured", + "emptyHint": "Add at least one available model to enable Auto in WebUI chats.", + "unavailable": "Unavailable", + "removeInvalidHint": "Remove retired, disabled, or primary-model entries before saving.", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove fallback", + "add": "Add fallback model", + "noModelsToAdd": "No more available models", + "close": "Close fallback models", + "cancel": "Cancel", + "save": "Save", + "saved": "Fallback models updated" + }, "providerList": { "empty": "No providers configured", "emptyHint": "Click \"Add Provider\" to get started", diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 4d8acb9ba..a5ca400f9 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -59,6 +59,9 @@ "title": "Choose Model", "hint": "Overrides the model used when sending this chat message", "empty": "No available models", + "auto": "Auto", + "autoHint": "Use the primary model, then fail over in order if it fails", + "autoUnavailable": "Configure a primary model and at least one available fallback", "count": "{{count}}", "vision": "Vision", "free": "Free", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index 4761c8ff1..1fa0b3f04 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -9,6 +9,10 @@ "setDefaultModel": "设置默认模型", "defaultModelUpdated": "默认模型已更新", "defaultModelInvalid": "当前默认模型「{{model}}」已不在可用列表中,已自动清除,请重新选择", + "fallbackModels": "备用模型", + "noFallbackModels": "未设置", + "fallbackAvailability": "{{available}} / {{total}} 可用", + "editFallbackModels": "编辑备用模型", "connected": "已连接 Provider", "availableModels": "可用模型", "totalUsage": "总用量", @@ -18,6 +22,23 @@ "noCost": "暂无费用", "toggleCurrency": "点击切换人民币 / 美元" }, + "fallbacks": { + "title": "备用模型", + "description": "Auto 会话在主模型失败后,会按此处顺序切换模型。", + "empty": "尚未配置备用模型", + "emptyHint": "添加至少一个可用模型后,WebUI 对话才能选择 Auto。", + "unavailable": "不可用", + "removeInvalidHint": "保存前请先移除已停用、已删除或与主模型重复的条目。", + "moveUp": "上移", + "moveDown": "下移", + "remove": "移除备用模型", + "add": "添加备用模型", + "noModelsToAdd": "没有更多可用模型", + "close": "关闭备用模型配置", + "cancel": "取消", + "save": "保存", + "saved": "备用模型已更新" + }, "providerList": { "empty": "尚未添加模型供应商", "emptyHint": "点击右上角「添加模型供应商」开始配置", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 322af418f..72513db30 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -59,6 +59,9 @@ "title": "选择模型", "hint": "作为本次对话发送时的模型覆盖", "empty": "暂无可用模型", + "auto": "Auto", + "autoHint": "优先使用主模型,失败时按顺序切换备用模型", + "autoUnavailable": "请先配置主模型和至少一个可用的备用模型", "count": "{{count}} 个", "vision": "视觉", "free": "免费", diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index cbd42e1d7..77196a8e5 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), getSummary: vi.fn(), getResolved: vi.fn(), + getFallbacks: vi.fn(), + setFallbacks: vi.fn(), listDefinitions: vi.fn(), catalogList: vi.fn(), createProvider: vi.fn(), @@ -30,6 +32,9 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { if (key === 'status.models') return `${params?.count ?? 0} models`; + if (key === 'dashboard.fallbackAvailability') { + return `${params?.available ?? 0} / ${params?.total ?? 0} available`; + } const translations: Record = { pageTitle: 'Models', pageDescription: 'Manage providers', @@ -138,6 +143,8 @@ vi.mock('@/api/provider', () => ({ }, defaultModelAPI: { getResolved: mocks.getResolved, + getFallbacks: mocks.getFallbacks, + setFallbacks: mocks.setFallbacks, delete: vi.fn(), set: vi.fn(), }, @@ -156,6 +163,8 @@ describe('ModelPage add provider dialog', () => { }); mocks.getSummary.mockResolvedValue({ data: null }); mocks.getResolved.mockResolvedValue({ data: null }); + mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); + mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); mocks.listDefinitions.mockResolvedValue({ data: { models: [] } }); mocks.catalogList.mockResolvedValue({ data: { @@ -264,6 +273,8 @@ describe('ModelPage configure provider dialog', () => { }); mocks.getSummary.mockResolvedValue({ data: null }); mocks.getResolved.mockResolvedValue({ data: null }); + mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); + mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); mocks.listDefinitions.mockResolvedValue({ data: { models: [model], total: 1 } }); mocks.catalogList.mockResolvedValue({ data: { @@ -368,3 +379,162 @@ describe('ModelPage configure provider dialog', () => { })); }); }); + +describe('ModelPage fallback model editor', () => { + const providers = [ + { + id: 'openai', + name: 'OpenAI', + source: 'config', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }, + { + id: 'minimax', + name: 'MiniMax', + source: 'config', + env: [], + key: null, + options: {}, + models: {}, + configured: true, + modelCount: 1, + category: 'connected', + }, + ]; + const models = [ + { + id: 'gpt-4o', + name: 'GPT-4o', + provider_id: 'openai', + model_type: 'llm', + status: 'active', + capabilities: { features: [], supports_streaming: true, supports_tools: true }, + }, + { + id: 'minimax-m3', + name: 'MiniMax M3', + provider_id: 'minimax', + model_type: 'llm', + status: 'active', + capabilities: { features: [], supports_streaming: true, supports_tools: true }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + mocks.useProviders.mockReturnValue({ + providers, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getSummary.mockResolvedValue({ data: null }); + mocks.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); + mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); + mocks.listDefinitions.mockResolvedValue({ data: { models, total: models.length } }); + mocks.getCredentials.mockResolvedValue({ data: null }); + mocks.testCredentials.mockResolvedValue({ data: { success: true, latency_ms: 10 } }); + }); + + it('adds and explicitly saves an ordered fallback list', async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.click(await screen.findByTitle('dashboard.editFallbackModels')); + await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); + const matchingModels = await screen.findAllByRole('button', { name: /MiniMax M3/i }); + await user.click(matchingModels[matchingModels.length - 1]); + await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); + + await waitFor(() => { + expect(mocks.setFallbacks).toHaveBeenCalledWith([ + { provider_id: 'minimax', model_id: 'minimax-m3' }, + ]); + }); + }); + + it('requires invalid entries to be removed before saving', async () => { + const user = userEvent.setup(); + mocks.getFallbacks.mockResolvedValue({ + data: { + fallback_providers: [ + { provider_id: 'missing', model_id: 'retired-model' }, + { provider_id: 'minimax', model_id: 'minimax-m3' }, + ], + }, + }); + renderWithRouter(); + + await user.click(await screen.findByTitle('dashboard.editFallbackModels')); + expect(await screen.findByText('fallbacks.unavailable')).toBeInTheDocument(); + expect(screen.getByText('fallbacks.removeInvalidHint')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); + + await user.click(screen.getAllByRole('button', { name: 'fallbacks.remove' })[0]); + expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeEnabled(); + await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); + + await waitFor(() => { + expect(mocks.setFallbacks).toHaveBeenCalledWith([ + { provider_id: 'minimax', model_id: 'minimax-m3' }, + ]); + }); + }); + + it('does not count a fallback from an unconfigured provider as available', async () => { + const user = userEvent.setup(); + mocks.useProviders.mockReturnValue({ + providers: [providers[0], { ...providers[1], configured: false }], + connectedIds: ['openai'], + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getFallbacks.mockResolvedValue({ + data: { + fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }], + }, + }); + renderWithRouter(); + + expect(await screen.findByText('0 / 1 available')).toBeInTheDocument(); + await user.click(screen.getByTitle('dashboard.editFallbackModels')); + expect(await screen.findByText('fallbacks.unavailable')).toBeInTheDocument(); + }); + + it('allows an unconfigured provider model to be saved for later repair', async () => { + const user = userEvent.setup(); + mocks.useProviders.mockReturnValue({ + providers: [providers[0], { ...providers[1], configured: false }], + connectedIds: ['openai'], + loading: false, + error: null, + refetch: mocks.refetch, + }); + renderWithRouter(); + + await user.click(await screen.findByTitle('dashboard.editFallbackModels')); + await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); + const matchingModels = await screen.findAllByRole('button', { name: /MiniMax M3/i }); + await user.click(matchingModels[matchingModels.length - 1]); + expect(screen.getByText('fallbacks.unavailable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeEnabled(); + await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); + + await waitFor(() => { + expect(mocks.setFallbacks).toHaveBeenCalledWith([ + { provider_id: 'minimax', model_id: 'minimax-m3' }, + ]); + }); + }); +}); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index b34dc23f1..988988644 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -7,7 +7,7 @@ import { Plus, ToggleLeft, ToggleRight, ChevronDown, Check, AlertCircle, Loader2, X, Shield, Pencil, Star, AlertTriangle, - CheckCircle2, + CheckCircle2, ArrowUp, ArrowDown, ListOrdered, } from 'lucide-react'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; @@ -17,6 +17,7 @@ import EntitySheet from '@/components/common/EntitySheet'; import { useProviders, type EnrichedProvider } from '@/hooks/useProviders'; import { useSSE } from '@/hooks/useSSE'; import { MODEL_CHANGED_EVENT } from '@/hooks/useDefaultModelVision'; +import { invalidateFallbackModels } from '@/hooks/useChatModelResources'; import { providerAPI, modelV2API, usageAPI, customAPI, modelSettingsAPI, catalogAPI, defaultModelAPI, @@ -32,6 +33,7 @@ import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, CustomModelCreate, ProviderCredentialInput, + FallbackModelRef, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -131,6 +133,9 @@ export default function ModelPage() { const [usageStats, setUsageStats] = useState(null); const [defaultModel, setDefaultModel] = useState<{ provider_id: string; model_id: string } | null>(null); const [showDefaultModelDialog, setShowDefaultModelDialog] = useState(false); + const [fallbackModels, setFallbackModels] = useState([]); + const [availableRoutingModels, setAvailableRoutingModels] = useState([]); + const [showFallbackModelsDialog, setShowFallbackModelsDialog] = useState(false); // Refs for latest handler/state values (avoid stale closures in SSE & one-time effects) const sseRefetchTimer = useRef(null); @@ -160,11 +165,15 @@ export default function ModelPage() { Promise.all([ defaultModelAPI.getResolved().catch(() => ({ data: null })), modelV2API.listDefinitions({ enabled_only: true }).catch(() => ({ data: { models: [] } })), - ]).then(([defaultRes, modelsRes]) => { + defaultModelAPI.getFallbacks().catch(() => ({ data: { fallback_providers: [] } })), + ]).then(([defaultRes, modelsRes, fallbackRes]) => { + const availableModels = modelsRes.data.models || []; + setAvailableRoutingModels(availableModels); + setFallbackModels(fallbackRes.data.fallback_providers || []); + const dm = defaultRes.data; if (!dm) return; - const availableModels = modelsRes.data.models || []; const isValid = availableModels.some( m => m.provider_id === dm.provider_id && m.id === dm.model_id ); @@ -202,6 +211,22 @@ export default function ModelPage() { [configuredProviders, connectionStatus] ); + const availableFallbackCount = useMemo(() => { + const configuredProviderIds = new Set(configuredProviders.map(provider => provider.id)); + const availableKeys = new Set( + availableRoutingModels + .filter(model => model.model_type === 'llm') + .map(model => `${model.provider_id}\u0000${model.id}`), + ); + return fallbackModels.filter(model => ( + configuredProviderIds.has(model.provider_id) + && availableKeys.has(`${model.provider_id}\u0000${model.model_id}`) + && !(defaultModel + && model.provider_id === defaultModel.provider_id + && model.model_id === defaultModel.model_id) + )).length; + }, [availableRoutingModels, configuredProviders, defaultModel, fallbackModels]); + // Auto-select last-used provider (persisted in sessionStorage), fallback to first const autoSelectedRef = useRef(false); useEffect(() => { @@ -472,6 +497,9 @@ export default function ModelPage() { usageStats={usageStats} defaultModel={defaultModel} onEditDefault={() => setShowDefaultModelDialog(true)} + fallbackCount={fallbackModels.length} + availableFallbackCount={availableFallbackCount} + onEditFallbacks={() => setShowFallbackModelsDialog(true)} /> {/* Main Content: Provider List + Detail Panel */} @@ -633,6 +661,20 @@ export default function ModelPage() { }} /> )} + + {showFallbackModelsDialog && ( + setShowFallbackModelsDialog(false)} + onSaved={(models, availableModels) => { + setFallbackModels(models); + setAvailableRoutingModels(availableModels); + setShowFallbackModelsDialog(false); + }} + /> + )} ); } @@ -645,12 +687,18 @@ function DashboardStrip({ usageStats, defaultModel, onEditDefault, + fallbackCount, + availableFallbackCount, + onEditFallbacks, }: { connectedCount: number; totalModels: number; usageStats: UsageStats | null; defaultModel: { provider_id: string; model_id: string } | null; onEditDefault: () => void; + fallbackCount: number; + availableFallbackCount: number; + onEditFallbacks: () => void; }) { const { t, i18n } = useTranslation('model'); const totalTokens = usageStats?.summary?.total_tokens ?? 0; @@ -666,7 +714,7 @@ function DashboardStrip({ }, [i18n.language]); return ( -
+
{/* Default Model Card */}
@@ -689,6 +737,17 @@ function DashboardStrip({
{defaultModel.provider_id}
)}
+ } + label={t('dashboard.fallbackModels')} + value={fallbackCount > 0 + ? t('dashboard.fallbackAvailability', { available: availableFallbackCount, total: fallbackCount }) + : t('dashboard.noFallbackModels')} + color="purple" + small={fallbackCount > 0} + onClick={onEditFallbacks} + title={t('dashboard.editFallbackModels')} + /> } label={t('dashboard.connected')} value={String(connectedCount)} color="green" /> } label={t('dashboard.availableModels')} value={String(totalModels)} color="blue" /> ); } + +// ==================== Runtime Fallback Models Dialog ==================== + +function FallbackModelsDialog({ + current, + primary, + providers, + onClose, + onSaved, +}: { + current: FallbackModelRef[]; + primary: { provider_id: string; model_id: string } | null; + providers: EnrichedProvider[]; + onClose: () => void; + onSaved: (models: FallbackModelRef[], availableModels: ModelDefinitionV2[]) => void; +}) { + const { t } = useTranslation('model'); + const toast = useToast(); + const [draft, setDraft] = useState(() => current.map(model => ({ ...model }))); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [adding, setAdding] = useState(false); + + useEffect(() => { + modelV2API.listDefinitions({ enabled_only: true }) + .then(response => setModels(response.data.models || [])) + .catch(() => setModels([])) + .finally(() => setLoading(false)); + }, []); + + const configuredProviderIds = useMemo( + () => new Set(providers.filter(provider => provider.configured).map(provider => provider.id)), + [providers], + ); + const providerNames = useMemo( + () => new Map(providers.map(provider => [provider.id, provider.name || provider.id])), + [providers], + ); + const validModels = useMemo( + () => models.filter(model => model.model_type === 'llm'), + [models], + ); + const validByKey = useMemo( + () => new Map(validModels.map(model => [`${model.provider_id}\u0000${model.id}`, model])), + [validModels], + ); + const availableModels = useMemo( + () => validModels.filter(model => ( + configuredProviderIds.has(model.provider_id) + )), + [configuredProviderIds, validModels], + ); + const availableByKey = useMemo( + () => new Map(availableModels.map(model => [`${model.provider_id}\u0000${model.id}`, model])), + [availableModels], + ); + const draftKeys = useMemo( + () => new Set(draft.map(model => `${model.provider_id}\u0000${model.model_id}`)), + [draft], + ); + const selectableGroups = useMemo(() => { + const grouped = new Map(); + validModels.forEach(model => { + const key = `${model.provider_id}\u0000${model.id}`; + const isPrimary = primary?.provider_id === model.provider_id && primary.model_id === model.id; + if (isPrimary || draftKeys.has(key)) return; + const entries = grouped.get(model.provider_id) ?? []; + entries.push(model); + grouped.set(model.provider_id, entries); + }); + return Array.from(grouped.entries()) + .map(([providerId, providerModels]) => ({ + providerId, + providerName: providerNames.get(providerId) || providerId, + models: providerModels.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)), + })) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + }, [draftKeys, primary, providerNames, validModels]); + const dirty = JSON.stringify(draft) !== JSON.stringify(current); + const hasInvalidDraft = useMemo( + () => draft.some((fallback) => { + const key = `${fallback.provider_id}\u0000${fallback.model_id}`; + const isPrimary = primary?.provider_id === fallback.provider_id + && primary.model_id === fallback.model_id; + return isPrimary || !validByKey.has(key); + }), + [draft, primary, validByKey], + ); + const closeButtonRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + closeButtonRef.current?.focus(); + return () => previouslyFocused?.focus(); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || saving) return; + event.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, saving]); + + const move = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= draft.length) return; + setDraft(previous => { + const next = [...previous]; + [next[index], next[target]] = [next[target], next[index]]; + return next; + }); + }; + + const handleSave = async () => { + if (!dirty || saving || hasInvalidDraft) return; + setSaving(true); + try { + await defaultModelAPI.setFallbacks(draft); + invalidateFallbackModels(); + toast.success(t('fallbacks.saved')); + onSaved(draft, models); + } catch (error: any) { + toast.error(t('operationFailed'), error?.message); + } finally { + setSaving(false); + } + }; + + return ( +
{ + if (!saving) onClose(); + }} + > +
event.stopPropagation()} + > +
+
+

{t('fallbacks.title')}

+

{t('fallbacks.description')}

+
+ +
+ +
+ {loading ? ( +
+ +
+ ) : ( +
+ {draft.length === 0 ? ( +
+ +

{t('fallbacks.empty')}

+

{t('fallbacks.emptyHint')}

+
+ ) : ( +
+ {draft.map((fallback, index) => { + const key = `${fallback.provider_id}\u0000${fallback.model_id}`; + const definition = availableByKey.get(key); + const isPrimary = primary?.provider_id === fallback.provider_id + && primary.model_id === fallback.model_id; + const available = Boolean(definition) && !isPrimary; + return ( +
+ + {index + 1} + +
+
+ + {definition?.name || fallback.model_id} + + {!available && ( + + {t('fallbacks.unavailable')} + + )} +
+
+ {providerNames.get(fallback.provider_id) || fallback.provider_id} / {fallback.model_id} +
+
+
+ + + +
+
+ ); + })} +
+ )} + + {hasInvalidDraft && ( +

+ {t('fallbacks.removeInvalidHint')} +

+ )} + + + + {adding && selectableGroups.length > 0 && ( +
+ {selectableGroups.map(group => ( +
+
+ {group.providerName} +
+ {group.models.map(model => ( + + ))} +
+ ))} +
+ )} +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index d1a0a8045..5a654c3fb 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -44,6 +44,7 @@ const { useProviders: vi.fn(), defaultModelAPI: { getResolved: vi.fn(), + getFallbacks: vi.fn(), }, modelV2API: { listDefinitions: vi.fn(), @@ -118,6 +119,8 @@ vi.mock('@/components/common/SessionChat', () => ({ onSSEEvent, agentName, model, + supportsVision, + contextWindowTokens, display, hideInput, }: { @@ -130,6 +133,8 @@ vi.mock('@/components/common/SessionChat', () => ({ initialMessage?: string | null; initialDisplayText?: string | null; model?: { providerID: string; modelID: string } | null; + supportsVision?: boolean; + contextWindowTokens?: number | null; hideInput?: boolean; display?: { compact?: boolean; @@ -155,6 +160,8 @@ vi.mock('@/components/common/SessionChat', () => ({ data-agent-name={agentName ?? ''} data-mention-agents={(mentionAgents ?? []).map((a) => a.name).join(',')} data-model={model ? `${model.providerID}/${model.modelID}` : ''} + data-supports-vision={String(Boolean(supportsVision))} + data-context-window={contextWindowTokens ?? ''} data-collapse-intermediate={String(Boolean(display?.collapseIntermediateSteps))} data-process-groups-default-open={String(Boolean(display?.processGroupsDefaultOpen))} data-process-groups-open-while-active={String(Boolean(display?.processGroupsOpenWhileActive))} @@ -310,6 +317,7 @@ describe('SessionPage session actions menu', () => { refetch: vi.fn(), }); defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: '', model_id: '' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); modelV2API.listDefinitions.mockResolvedValue({ data: { models: [] } }); client.get.mockResolvedValue({ data: [{ @@ -1691,7 +1699,7 @@ describe('SessionPage session actions menu', () => { await waitFor(() => { expect(screen.getByTestId('session-chat')).toHaveAttribute('data-model', 'minimax/minimax-m3'); }); - expect(defaultModelAPI.getResolved).not.toHaveBeenCalled(); + expect(defaultModelAPI.getResolved).toHaveBeenCalledTimes(1); }); it('persists model changes to the selected session', async () => { @@ -1736,11 +1744,269 @@ describe('SessionPage session actions menu', () => { provider: 'minimax', model: 'minimax-m3', model_pinned: true, + model_auto: false, }); }); expect(refetchSessions).toHaveBeenCalled(); }); + it('switches a pinned session to Auto without sending a synthetic model', async () => { + const user = userEvent.setup(); + useSessions.mockReturnValue({ + sessions: [{ + ...session, + provider: 'minimax', + model: 'minimax-m3', + model_pinned: true, + }], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ + data: { fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }] }, + }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + + renderSessionPage('/sessions?session=session-1'); + + await waitFor(() => { + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-model', 'minimax/minimax-m3'); + }); + await user.click(screen.getByRole('button', { name: /MiniMax M3/i })); + await user.click((await screen.findByText('modelPicker.autoHint')).closest('button')!); + + await waitFor(() => { + expect(sessionApi.update).toHaveBeenCalledWith('session-1', { + model_auto: true, + model_pinned: false, + }); + }); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-model', ''); + }); + + it('uses primary model capabilities while Auto is selected', async () => { + useSessions.mockReturnValue({ + sessions: [{ ...session, model_auto: true, model_pinned: false }], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ + data: { fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }] }, + }); + modelV2API.listDefinitions.mockResolvedValue({ + data: { + models: modelDefinitions.map((definition) => definition.id === 'gpt-4o' + ? { + ...definition, + capabilities: { supports_vision: true }, + limits: { context_window: 200000 }, + } + : { + ...definition, + capabilities: { supports_vision: false }, + limits: { context_window: 32000 }, + }), + }, + }); + + renderSessionPage('/sessions?session=session-1'); + + await waitFor(() => { + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-supports-vision', 'true'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-context-window', '200000'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-model', ''); + }); + }); + + it('turns Auto off when a concrete model is selected', async () => { + const user = userEvent.setup(); + useSessions.mockReturnValue({ + sessions: [{ ...session, model_auto: true, model_pinned: false }], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ + data: { fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }] }, + }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + + renderSessionPage('/sessions?session=session-1'); + + await user.click(await screen.findByRole('button', { name: /^modelPicker\.auto/i })); + await user.click(screen.getByRole('button', { name: /MiniMax M3/i })); + + await waitFor(() => { + expect(sessionApi.update).toHaveBeenCalledWith('session-1', { + provider: 'minimax', + model: 'minimax-m3', + model_pinned: true, + model_auto: false, + }); + }); + }); + + it('keeps an existing Auto session selected when no fallback is currently available', async () => { + useSessions.mockReturnValue({ + sessions: [{ ...session, model_auto: true, model_pinned: false }], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + + renderSessionPage('/sessions?session=session-1'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^modelPicker\.auto/i })).toBeInTheDocument(); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-model', ''); + }); + }); + + it('does not enable Auto when the only fallback is not an LLM', async () => { + const user = userEvent.setup(); + useSessions.mockReturnValue({ + sessions: [session], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ + data: { fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-embed' }] }, + }); + modelV2API.listDefinitions.mockResolvedValue({ + data: { + models: [ + ...modelDefinitions, + { + provider_id: 'minimax', + id: 'minimax-embed', + name: 'MiniMax Embed', + model_type: 'text-embedding', + source: 'predefined', + capabilities: {}, + pricing: null, + limits: {}, + }, + ], + }, + }); + + renderSessionPage('/sessions?session=session-1'); + + await user.click(await screen.findByRole('button', { name: /GPT-4o/i })); + const autoButton = (await screen.findByText('modelPicker.autoUnavailable')).closest('button'); + expect(autoButton).toBeDisabled(); + expect(sessionApi.update).not.toHaveBeenCalled(); + }); + + it('creates a blank-session Auto chat without a model override', async () => { + const user = userEvent.setup(); + useSessions.mockReturnValue({ + sessions: [], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + defaultModelAPI.getFallbacks.mockResolvedValue({ + data: { fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }] }, + }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + + renderSessionPage(); + + await user.click(await screen.findByRole('button', { name: /GPT-4o/i })); + await user.click((await screen.findByText('modelPicker.autoHint')).closest('button')!); + await user.click(screen.getByRole('button', { name: 'mock-create-and-send' })); + + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/api/session', { + title: 'New Session', + projectID: 'default', + model_auto: true, + }); + expect(client.post).toHaveBeenCalledWith( + '/api/session/session-2/prompt_async', + expect.not.objectContaining({ model: expect.anything() }), + ); + }); + }); + it('resets the selected model to the default when creating a new session', async () => { const user = userEvent.setup(); useSessions.mockReturnValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 4c7454d9a..1206bb413 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -25,7 +25,11 @@ import type { Agent } from '@/api/agent'; import { useSessions } from '@/hooks/useSessions'; import { useAgents } from '@/hooks/useAgents'; import { useProviders } from '@/hooks/useProviders'; -import { useEnabledChatModelDefinitions, useResolvedDefaultModel } from '@/hooks/useChatModelResources'; +import { + useEnabledChatModelDefinitions, + useFallbackModels, + useResolvedDefaultModel, +} from '@/hooks/useChatModelResources'; import client from '@/api/client'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; @@ -48,6 +52,7 @@ const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; const SOC_WORKSPACE_COMPONENT_ID = 'soc-workspace'; const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable']); const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; +const AUTO_MODEL_KEY = '__flocks_auto__'; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; type ProjectSummary = { id: string; @@ -420,6 +425,10 @@ export default function SessionPage() { data: enabledModelDefinitions, loading: loadingEnabledModels, } = useEnabledChatModelDefinitions(); + const { + data: fallbackModels, + loading: loadingFallbackModels, + } = useFallbackModels(); const primaryAgents = useMemo(() => agents.filter((a) => a.mode === 'primary' && isAgentUsableInChat(a)), [agents]); const subAgents = useMemo( () => agents.filter((a) => a.mode !== 'primary' && isAgentUsableInChat(a)), @@ -461,6 +470,7 @@ export default function SessionPage() { }; return enabledModelDefinitions.flatMap((model) => { + if (model.model_type !== 'llm') return []; const provider = providerById.get(model.provider_id); if (!provider) return []; return [{ @@ -515,18 +525,50 @@ export default function SessionPage() { ? makeModelKey(selectedSession.provider, selectedSession.model) : null; const hasPinnedModelOption = !!pinnedModelKey && chatModelOptions.some((option) => option.key === pinnedModelKey); + const selectedModelAuto = selectedModelKey === AUTO_MODEL_KEY; const selectedModelOption = useMemo( () => chatModelOptions.find((option) => option.key === selectedModelKey) ?? (selectedModelKey ? null : chatModelOptions[0] ?? null), [chatModelOptions, selectedModelKey], ); - const selectedPromptModel = selectedModelOption - ? { providerID: selectedModelOption.providerID, modelID: selectedModelOption.modelID } - : null; const { data: resolvedDefaultModel, initialized: resolvedDefaultModelInitialized, - } = useResolvedDefaultModel(chatModelOptions.length > 0 && !hasPinnedModelOption); - const effectiveSupportsVision = selectedModelOption?.supportsVision ?? supportsVision; + } = useResolvedDefaultModel(chatModelOptions.length > 0); + const primaryModelOption = useMemo(() => { + if (!resolvedDefaultModel) return null; + const key = makeModelKey(resolvedDefaultModel.providerID, resolvedDefaultModel.modelID); + return chatModelOptions.find((option) => option.key === key) ?? null; + }, [chatModelOptions, resolvedDefaultModel]); + const availableFallbackOptions = useMemo(() => { + const primaryKey = primaryModelOption?.key; + return fallbackModels.flatMap((fallback) => { + const option = chatModelOptions.find( + (candidate) => candidate.key === makeModelKey(fallback.provider_id, fallback.model_id), + ); + return option && option.key !== primaryKey ? [option] : []; + }); + }, [chatModelOptions, fallbackModels, primaryModelOption?.key]); + const canSelectAuto = Boolean(primaryModelOption && availableFallbackOptions.length > 0); + const effectiveModelOption = selectedModelAuto ? primaryModelOption : selectedModelOption; + const selectedPromptModel = selectedModelAuto + ? null + : selectedModelOption + ? { providerID: selectedModelOption.providerID, modelID: selectedModelOption.modelID } + : null; + const effectiveSupportsVision = effectiveModelOption?.supportsVision ?? supportsVision; + const autoRouteLabel = primaryModelOption + ? [primaryModelOption, ...availableFallbackOptions] + .map((option) => `${option.providerName} / ${option.label}`) + .join(' → ') + : t('modelPicker.autoUnavailable'); + const firstChatModelKey = chatModelOptions[0]?.key ?? null; + const resolvedDefaultKey = resolvedDefaultModel + ? makeModelKey(resolvedDefaultModel.providerID, resolvedDefaultModel.modelID) + : null; + const defaultSelectionKey = resolvedDefaultKey + && chatModelOptions.some(option => option.key === resolvedDefaultKey) + ? resolvedDefaultKey + : firstChatModelKey; const toggleProjectCollapsed = useCallback((projectId: string) => { setCollapsedProjectIds(prev => { @@ -837,7 +879,12 @@ export default function SessionPage() { }, [showModelOptions]); useEffect(() => { - if (chatModelOptions.length === 0) { + if (selectedSession?.model_auto) { + setSelectedModelKey(AUTO_MODEL_KEY); + return; + } + + if (!firstChatModelKey) { setSelectedModelKey(null); return; } @@ -849,25 +896,22 @@ export default function SessionPage() { setSelectedModelKey(null); if (!resolvedDefaultModelInitialized) return; - const defaultKey = resolvedDefaultModel - ? makeModelKey(resolvedDefaultModel.providerID, resolvedDefaultModel.modelID) - : null; - const fallbackKey = chatModelOptions[0]?.key ?? null; - setSelectedModelKey(defaultKey && chatModelOptions.some((option) => option.key === defaultKey) ? defaultKey : fallbackKey); + setSelectedModelKey(defaultSelectionKey); }, [ - chatModelOptions, + defaultSelectionKey, + firstChatModelKey, hasPinnedModelOption, pinnedModelKey, - resolvedDefaultModel, resolvedDefaultModelInitialized, + selectedSession?.model_auto, selectedSessionId, ]); useEffect(() => { - if (loadingEnabledModels || chatModelOptions.length === 0 || !selectedModelKey) return; + if (loadingEnabledModels || chatModelOptions.length === 0 || !selectedModelKey || selectedModelAuto) return; if (chatModelOptions.some((option) => option.key === selectedModelKey)) return; setSelectedModelKey(chatModelOptions[0].key); - }, [chatModelOptions, loadingEnabledModels, selectedModelKey]); + }, [chatModelOptions, loadingEnabledModels, selectedModelAuto, selectedModelKey]); useEffect(() => { if (showAgentOptions || showModelOptions) return; @@ -903,11 +947,13 @@ export default function SessionPage() { const handleCreateSession = useCallback(async (projectIdOverride?: string) => { if (creating) return; const projectID = projectIdOverride ?? selectedProjectIDForCreate; + const carryAutoSelection = !selectedSessionId && selectedModelAuto; setCreating(true); try { const response = await client.post('/api/session', { title: 'New Session', ...(projectID ? { projectID } : {}), + ...(carryAutoSelection ? { model_auto: true } : {}), }); addSession(response.data); await fetchProjects(undefined, searchQuery); @@ -921,20 +967,21 @@ export default function SessionPage() { }); } setSelectedAgent('rex'); - setSelectedModelKey(null); + setSelectedModelKey(carryAutoSelection ? AUTO_MODEL_KEY : null); setSelectedSessionId(response.data.id); } catch (err: any) { toast.error(t('createFailed'), err.message); } finally { setCreating(false); } - }, [creating, selectedProjectIDForCreate, addSession, fetchProjects, searchQuery, toast, t]); + }, [creating, selectedProjectIDForCreate, selectedSessionId, selectedModelAuto, addSession, fetchProjects, searchQuery, toast, t]); const handleCreateSessionInProject = useCallback((projectId: string) => { void handleCreateSession(projectId); }, [handleCreateSession]); const handleSelectModel = useCallback(async (option: ChatModelOption) => { + const previousModelKey = selectedModelKey; setSelectedModelKey(option.key); setShowModelOptions(false); if (!selectedSessionId) return; @@ -944,12 +991,33 @@ export default function SessionPage() { provider: option.providerID, model: option.modelID, model_pinned: true, + model_auto: false, + }); + refetchSessions(); + } catch (err: any) { + setSelectedModelKey(previousModelKey); + toast.error(t('chat.error', 'Error'), err.message); + } + }, [refetchSessions, selectedModelKey, selectedSessionId, toast, t]); + + const handleSelectAutoModel = useCallback(async () => { + if (!canSelectAuto && !selectedModelAuto) return; + const previousModelKey = selectedModelKey; + setSelectedModelKey(AUTO_MODEL_KEY); + setShowModelOptions(false); + if (!selectedSessionId) return; + + try { + await sessionApi.update(selectedSessionId, { + model_auto: true, + model_pinned: false, }); refetchSessions(); } catch (err: any) { + setSelectedModelKey(previousModelKey); toast.error(t('chat.error', 'Error'), err.message); } - }, [refetchSessions, selectedSessionId, toast, t]); + }, [canSelectAuto, refetchSessions, selectedModelAuto, selectedModelKey, selectedSessionId, toast, t]); const handleCreateAndSend = useCallback(async ( text: string, @@ -962,13 +1030,14 @@ export default function SessionPage() { const response = await client.post('/api/session', { title: 'New Session', ...(selectedProjectIDForCreate ? { projectID: selectedProjectIDForCreate } : {}), + ...(selectedModelAuto ? { model_auto: true } : {}), }); const newSessionId = response.data.id; addSession(response.data); await fetchProjects(undefined, searchQuery); setSelectedSessionFallback(response.data); - setSelectedModelKey(null); + setSelectedModelKey(selectedModelAuto ? AUTO_MODEL_KEY : null); setSelectedSessionId(newSessionId); const payload: Record = { @@ -976,7 +1045,7 @@ export default function SessionPage() { }; const effectiveAgent = agentOverride || selectedAgent || 'rex'; if (effectiveAgent) payload.agent = effectiveAgent; - if (modelOverride) payload.model = modelOverride; + if (!selectedModelAuto && modelOverride) payload.model = modelOverride; if (options?.displayText) payload.displayText = options.displayText; client.post(`/api/session/${newSessionId}/prompt_async`, payload).catch((err: any) => { toast.error(t('chat.sendFailed', 'Send failed'), err.message); @@ -984,7 +1053,7 @@ export default function SessionPage() { } catch (err: any) { toast.error(t('createFailed'), err.message); } - }, [addSession, fetchProjects, searchQuery, selectedAgent, selectedProjectIDForCreate, toast, t]); + }, [addSession, fetchProjects, searchQuery, selectedAgent, selectedModelAuto, selectedProjectIDForCreate, toast, t]); const handleSuiteInstallProgress = useCallback((progress: HubInstallProgressEvent) => { setSuiteInstallProgress(current => applySuiteInstallProgressEvent(current, progress)); @@ -1976,7 +2045,7 @@ export default function SessionPage() { setPendingInitialDisplayText(null); }} supportsVision={effectiveSupportsVision} - contextWindowTokens={selectedModelOption?.contextWindowTokens ?? null} + contextWindowTokens={effectiveModelOption?.contextWindowTokens ?? null} model={selectedPromptModel} welcomeContent={(setInput) => ( setShowModelOptions(!showModelOptions)} disabled={loadingProviders || loadingEnabledModels || chatModelOptions.length === 0} className="flex h-7 w-[132px] min-w-0 items-center gap-1.5 rounded-lg px-2 text-xs text-zinc-600 transition-colors hover:bg-zinc-200/60 hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100" - title={selectedModelOption ? `${selectedModelOption.providerName} / ${selectedModelOption.modelID}` : t('modelPicker.empty')} + title={selectedModelAuto + ? `${t('modelPicker.auto')}: ${autoRouteLabel}` + : selectedModelOption + ? `${selectedModelOption.providerName} / ${selectedModelOption.modelID}` + : t('modelPicker.empty')} > - + {selectedModelAuto + ? + : } - {selectedModelOption?.label ?? (loadingProviders || loadingEnabledModels ? t('loading') : t('modelPicker.empty'))} + {selectedModelAuto + ? t('modelPicker.auto') + : selectedModelOption?.label ?? (loadingProviders || loadingEnabledModels ? t('loading') : t('modelPicker.empty'))} @@ -2116,11 +2193,48 @@ export default function SessionPage() {
{t('modelPicker.title')}
{t('modelPicker.hint')}
-
- {loadingProviders || loadingEnabledModels ? ( +
+ {loadingProviders || loadingEnabledModels || loadingFallbackModels ? (
{t('loading')}
- ) : groupedChatModelOptions.length > 0 ? ( - groupedChatModelOptions.map((group) => ( + ) : ( + <> +
+ +
+ {groupedChatModelOptions.length > 0 ? groupedChatModelOptions.map((group) => (
{group.providerName} @@ -2167,9 +2281,10 @@ export default function SessionPage() { ))}
- )) - ) : ( -
{t('modelPicker.empty')}
+ )) : ( +
{t('modelPicker.empty')}
+ )} + )}
diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index 7314c7725..c0831820b 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -21,6 +21,8 @@ export interface Session { provider?: string; model?: string; model_pinned?: boolean; + /** Runtime provider failover is enabled only after an explicit WebUI selection. */ + model_auto?: boolean; ownerUserID?: string; ownerUsername?: string; canWrite?: boolean; @@ -515,6 +517,15 @@ export interface DefaultModelConfig { model_id: string; } +export interface FallbackModelRef { + provider_id: string; + model_id: string; +} + +export interface FallbackModelsConfig { + fallback_providers: FallbackModelRef[]; +} + /** Usage summary from /api/usage/summary */ export interface UsageStats { summary: { From 443779c311c56c02386fccbad6014c533b1dff99 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 09:55:50 +0800 Subject: [PATCH 03/87] fix(webui): harden auto model failover --- flocks/config/config.py | 91 ++++---- flocks/config/config_writer.py | 52 ++++- flocks/server/routes/default_model.py | 21 +- flocks/server/routes/provider.py | 2 + flocks/session/runner.py | 53 ++++- flocks/session/session_loop.py | 29 ++- tests/config/test_config_writer.py | 99 ++++++++ .../routes/test_default_model_fallbacks.py | 51 ++++- .../test_provider_route_responsiveness.py | 34 +++ tests/session/test_auto_model_failover.py | 214 ++++++++++++++++++ webui/src/hooks/useProviders.test.tsx | 23 ++ webui/src/hooks/useProviders.ts | 4 +- webui/src/locales/en-US/model.json | 2 + webui/src/locales/zh-CN/model.json | 2 + webui/src/pages/Model/index.test.tsx | 39 ++++ webui/src/pages/Model/index.tsx | 97 ++++++-- webui/src/pages/Session/index.test.tsx | 64 ++++++ webui/src/pages/Session/index.tsx | 15 +- webui/src/types/index.ts | 3 +- 19 files changed, 805 insertions(+), 90 deletions(-) diff --git a/flocks/config/config.py b/flocks/config/config.py index 7defe4c4e..910041c79 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -1252,6 +1252,49 @@ async def load_file(cls, filepath: Path) -> ConfigInfo: raise ValueError(f"Failed to read config file {filepath}: {e}") return await cls.load_text(text, filepath) + + @staticmethod + def parse_jsonc(text: str, filepath: Path) -> Dict[str, Any]: + """Parse JSON or JSONC text without resolving configuration values.""" + try: + # Remove block comments before processing line comments. This is + # the same JSONC syntax accepted by ``load_text``. + text_no_comments = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL) + + cleaned_lines = [] + for line in text_no_comments.split('\n'): + in_string = False + escape_next = False + comment_start = -1 + + for index, char in enumerate(line): + if escape_next: + escape_next = False + continue + if char == '\\': + escape_next = True + continue + if char == '"': + in_string = not in_string + if ( + not in_string + and index < len(line) - 1 + and line[index:index + 2] == '//' + ): + comment_start = index + break + + if comment_start >= 0: + line = line[:comment_start] + cleaned_lines.append(line) + + data = json.loads('\n'.join(cleaned_lines)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in {filepath}: {exc}") from exc + + if not isinstance(data, dict): + raise ValueError(f"Invalid configuration in {filepath}: expected an object") + return data @classmethod async def load_text(cls, text: str, filepath: Path) -> ConfigInfo: @@ -1276,52 +1319,8 @@ async def load_text(cls, text: str, filepath: Path) -> ConfigInfo: # Replace file references text = await cls.replace_file_refs(text, filepath.parent) - # Try to parse as JSONC (JSON with comments) - try: - # Remove comments properly - # 1. Remove /* */ block comments first - text_no_comments = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL) - - # 2. Remove // line comments, but NOT in strings! - # We need to be careful not to remove // inside quoted strings (like URLs) - # This regex matches // that are NOT inside quotes - # Negative lookbehind to avoid matching inside strings - lines = text_no_comments.split('\n') - cleaned_lines = [] - for line in lines: - # Find // but not inside strings - # Simple approach: find first // that is not between quotes - in_string = False - escape_next = False - comment_start = -1 - - for i, char in enumerate(line): - if escape_next: - escape_next = False - continue - - if char == '\\': - escape_next = True - continue - - if char == '"' and not escape_next: - in_string = not in_string - - if not in_string and i < len(line) - 1 and line[i:i+2] == '//': - comment_start = i - break - - if comment_start >= 0: - line = line[:comment_start] - - cleaned_lines.append(line) - - text_no_comments = '\n'.join(cleaned_lines) - - # Parse JSON - data = json.loads(text_no_comments) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in {filepath}: {e}") + # Try to parse as JSONC (JSON with comments). + data = cls.parse_jsonc(text, filepath) # Validate and parse with Pydantic try: diff --git a/flocks/config/config_writer.py b/flocks/config/config_writer.py index a7eaf8da1..303e49b3d 100644 --- a/flocks/config/config_writer.py +++ b/flocks/config/config_writer.py @@ -93,18 +93,62 @@ def _get_config_path(cls) -> Path: @classmethod def _read_raw(cls) -> Dict[str, Any]: """Read flocks.json as raw dict (no secret resolution).""" - path = cls._get_config_path() + return cls._read_path_raw(cls._get_config_path()) + + @classmethod + def _read_path_raw( + cls, + path: Path, + *, + strict: bool = False, + ) -> Dict[str, Any]: + """Read a JSON/JSONC file without resolving secrets or references.""" if not path.exists(): return {} try: text = path.read_text(encoding="utf-8") if not text.strip(): return {} - return json.loads(text) - except (json.JSONDecodeError, OSError) as exc: + return Config.parse_jsonc(text, path) + except (ValueError, OSError) as exc: log.error("config_writer.read_failed", {"path": str(path), "error": str(exc)}) + if strict: + raise ValueError( + f"Unable to read config file {path}: {exc}" + ) from exc return {} + @classmethod + def get_fallback_override_source(cls) -> Optional[str]: + """Return a higher-priority source overriding the writable fallback list.""" + writable_path = cls._get_config_path().resolve() + global_config = Config.get_global() + + inline_content = global_config.config_content + if inline_content: + try: + inline_data = json.loads(inline_content) + except json.JSONDecodeError: + inline_data = None + if ( + isinstance(inline_data, dict) + and inline_data.get("fallback_providers") is not None + ): + return "FLOCKS_CONFIG_CONTENT" + + candidates = [] + if global_config.config_path: + candidates.append(("FLOCKS_CONFIG", Path(global_config.config_path))) + candidates.append(("config.json", global_config.config_dir / "config.json")) + + for source, path in candidates: + if not path.exists() or path.resolve() == writable_path: + continue + data = cls._read_path_raw(path, strict=True) + if data.get("fallback_providers") is not None: + return source + return None + @classmethod def _write_raw(cls, data: Dict[str, Any]) -> None: """Atomic write: write to tmp file then rename, then clear Config cache.""" @@ -476,7 +520,7 @@ def set_fallback_providers( An empty list removes the top-level key instead of persisting redundant empty configuration. Callers are responsible for validating identities. """ - data = cls._read_raw() + data = cls._read_path_raw(cls._get_config_path(), strict=True) if fallbacks: data["fallback_providers"] = [ { diff --git a/flocks/server/routes/default_model.py b/flocks/server/routes/default_model.py index 2a4b86de7..92cde1604 100644 --- a/flocks/server/routes/default_model.py +++ b/flocks/server/routes/default_model.py @@ -83,8 +83,9 @@ async def get_resolved_default_model(): ) async def get_fallback_providers() -> FallbackProvidersConfig: """Return the ordered, structurally valid fallback model list.""" + config = await Config.get() return FallbackProvidersConfig( - fallback_providers=ConfigWriter.get_fallback_providers() + fallback_providers=config.fallback_providers or [] ) @@ -98,6 +99,16 @@ async def set_fallback_providers( body: FallbackProvidersConfig, ) -> FallbackProvidersConfig: """Validate and atomically replace the runtime fallback model list.""" + override_source = ConfigWriter.get_fallback_override_source() + if override_source: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "Fallback models are overridden by " + f"{override_source} and cannot be changed from WebUI" + ), + ) + config = await Config.get() await Provider.apply_config(config) manager = get_model_manager() @@ -175,7 +186,13 @@ async def set_fallback_providers( "model_id": model_id, }) - ConfigWriter.set_fallback_providers(normalized) + try: + ConfigWriter.set_fallback_providers(normalized) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc return FallbackProvidersConfig(fallback_providers=normalized) diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index f05768049..e7b9c6603 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -500,6 +500,7 @@ class ProviderInfo(BaseModel): source: str = Field(default="config", description="Provider source: env, config, custom, api") env: List[str] = Field(default_factory=list, description="Environment variable names") key: Optional[str] = Field(None, description="API key (if configured)") + configured: bool = Field(False, description="Whether runtime credentials are configured") options: Dict[str, Any] = Field(default_factory=dict, description="Provider options") # Flocks expects models as Dict[modelID, Model], not List[Model] models: Dict[str, Dict[str, Any]] = Field(default_factory=dict, description="Available models") @@ -596,6 +597,7 @@ async def list_providers() -> ProviderListResponse: source="config", # Provider source: env, config, custom, api env=[], # Environment variable names (can be enhanced later) key=None, # API key not exposed in list + configured=provider.is_configured(), options={}, models=models_dict, ) diff --git a/flocks/session/runner.py b/flocks/session/runner.py index 67266afc8..755658f3d 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -1074,7 +1074,8 @@ def classify_failover_error(error: Dict[str, Any]) -> FailoverDecision: return FailoverDecision(True, "server_error", 3) if any(pattern in lowered for pattern in ( - "content policy", "content filter", "safety policy", "policy violation", + "content policy", "content filter", "content_filter", "safety policy", + "policy violation", )): return FailoverDecision(True, "content_policy", 0) if error_name == "JSONDecodeError" or any( @@ -1579,7 +1580,6 @@ async def device_asset_prompt_factory() -> Optional[str]: retry_limit = MAX_ERROR_RETRIES if ( self._defer_step_errors - and self._failover_available and failover_decision.eligible ): retry_limit = failover_decision.same_model_retries @@ -2070,9 +2070,42 @@ def _exception_to_error_dict(self, exception: Exception) -> Dict[str, Any]: "transportExceptionModule": type(transport_exception).__module__, }) - # Check if it's an API error with specific attributes - if hasattr(exception, 'status_code'): - status_code = getattr(exception, 'status_code') + # Provider SDKs expose HTTP status through several shapes. Walk the + # normal exception chain so lightweight wrapper errors do not hide it. + status_code = None + status_exception = None + seen: set[int] = set() + current: Optional[BaseException] = exception + while current is not None and id(current) not in seen: + seen.add(id(current)) + response = getattr(current, "response", None) + status_values = ( + getattr(current, "status_code", None), + getattr(response, "status_code", None), + getattr(current, "code", None), + ) + for value in status_values: + if callable(value): + try: + value = value() + except TypeError: + continue + value = getattr(value, "value", value) + if isinstance(value, tuple) and value: + value = value[0] + try: + normalized = int(value) + except (TypeError, ValueError): + continue + if 100 <= normalized <= 599: + status_code = normalized + status_exception = current + break + if status_code is not None: + break + current = current.__cause__ or current.__context__ + + if status_code is not None: error_dict["name"] = "APIError" error_dict["data"]["statusCode"] = status_code @@ -2081,9 +2114,13 @@ def _exception_to_error_dict(self, exception: Exception) -> Dict[str, Any]: error_dict["data"]["isRetryable"] = is_retryable # Extract response headers if available - if hasattr(exception, 'response') and hasattr(exception.response, 'headers'): - headers = dict(exception.response.headers) - error_dict["data"]["responseHeaders"] = headers + response = getattr(status_exception, "response", None) + headers = getattr(response, "headers", None) + if headers is not None: + try: + error_dict["data"]["responseHeaders"] = dict(headers) + except (TypeError, ValueError): + pass # Check for common retryable error patterns error_msg = str(exception).lower() diff --git a/flocks/session/session_loop.py b/flocks/session/session_loop.py index a5616d06c..547f1fecc 100644 --- a/flocks/session/session_loop.py +++ b/flocks/session/session_loop.py @@ -446,7 +446,7 @@ async def _detect_queued_user_message( _session_id: str, post_messages: List[MessageInfo], current_user_id: str, - last_message: Optional[MessageInfo], + _last_message: Optional[MessageInfo], ) -> Optional[MessageInfo]: if not post_messages: return None @@ -461,11 +461,11 @@ async def _detect_queued_user_message( return None if newest_user.id <= current_user_id: return None - if last_message is None: - return newest_user - if newest_user.id > last_message.id: - return newest_user - return None + # A fallback assistant is created after a user message that arrived + # while the primary model was running. Its newer ID must not make that + # user message look handled; the current turn's user ID is the stable + # boundary for queued work. + return newest_user @classmethod async def run( @@ -865,9 +865,22 @@ async def _finalize_deferred_failure( cls, ctx: LoopContext, failure: Any, + last_user: MessageInfo, ) -> None: """Persist only the final Auto candidate failure.""" if not failure.assistant_message_id: + assistant = await Message.create( + session_id=ctx.session.id, + role=MessageRole.ASSISTANT, + content="", + agent=getattr(last_user, "agent", None) or ctx.agent_name or "rex", + model_id=ctx.model_id, + provider_id=ctx.provider_id, + parent_id=last_user.id, + error=failure.error_data, + finish="error", + ) + failure.assistant_message_id = assistant.id return await Message.update( ctx.session.id, @@ -938,7 +951,7 @@ async def _process_step_with_failover( expires_at=expires_at, reason="chain_exhausted", ) - await cls._finalize_deferred_failure(ctx, failure) + await cls._finalize_deferred_failure(ctx, failure, last_user) return step_result # A candidate may be removed only while its attempt is completely @@ -958,7 +971,7 @@ async def _process_step_with_failover( "error": str(exc), }) if not deleted: - await cls._finalize_deferred_failure(ctx, failure) + await cls._finalize_deferred_failure(ctx, failure, last_user) return step_result await cls._publish_runtime_event(callbacks, "message.removed", { "sessionID": ctx.session.id, diff --git a/tests/config/test_config_writer.py b/tests/config/test_config_writer.py index 1940701c5..af35c4ed0 100644 --- a/tests/config/test_config_writer.py +++ b/tests/config/test_config_writer.py @@ -467,6 +467,105 @@ def test_set_and_get_fallback_providers_preserves_order(self, temp_project): assert ConfigWriter.get_fallback_providers() == fallbacks + def test_set_fallback_providers_reads_jsonc_without_losing_other_config( + self, temp_project + ): + from flocks.config.config_writer import ConfigWriter + + jsonc_path = temp_project / "flocks.jsonc" + jsonc_path.write_text( + """ + { + // Keep the provider configuration when updating fallbacks. + "provider": { + "anthropic": { + "models": {"claude-sonnet": {}} + } + }, + "smallModel": "anthropic/claude-haiku" + } + """, + encoding="utf-8", + ) + + ConfigWriter.set_fallback_providers([ + {"provider_id": "anthropic", "model_id": "claude-sonnet"}, + ]) + + updated = json.loads(jsonc_path.read_text(encoding="utf-8")) + assert "anthropic" in updated["provider"] + assert updated["smallModel"] == "anthropic/claude-haiku" + assert updated["fallback_providers"] == [ + {"provider_id": "anthropic", "model_id": "claude-sonnet"}, + ] + + def test_set_fallback_providers_does_not_write_when_config_is_invalid( + self, temp_project + ): + from flocks.config.config_writer import ConfigWriter + + config_path = temp_project / "flocks.json" + invalid_content = '{"provider": {' + config_path.write_text(invalid_content, encoding="utf-8") + + with pytest.raises(ValueError, match="Unable to read config file"): + ConfigWriter.set_fallback_providers([ + {"provider_id": "anthropic", "model_id": "claude-sonnet"}, + ]) + + assert config_path.read_text(encoding="utf-8") == invalid_content + + def test_detects_inline_fallback_override( + self, temp_project, monkeypatch + ): + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv( + "FLOCKS_CONFIG_CONTENT", + json.dumps({"fallback_providers": []}), + ) + Config._global_config = None + + assert ( + ConfigWriter.get_fallback_override_source() + == "FLOCKS_CONFIG_CONTENT" + ) + + def test_detects_custom_config_fallback_override(self, temp_project, tmp_path): + from flocks.config.config_writer import ConfigWriter + + custom_path = tmp_path / "custom.jsonc" + custom_path.write_text( + '{"fallback_providers": [/* configured elsewhere */ ' + '{"provider_id": "openai", "model_id": "gpt-4o"}]}', + encoding="utf-8", + ) + Config.get_global().config_path = str(custom_path) + + assert ConfigWriter.get_fallback_override_source() == "FLOCKS_CONFIG" + + def test_detects_config_json_fallback_override(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + (temp_project / "config.json").write_text( + json.dumps({"fallback_providers": []}), + encoding="utf-8", + ) + + assert ConfigWriter.get_fallback_override_source() == "config.json" + + def test_higher_priority_config_without_fallback_does_not_block_save( + self, temp_project + ): + from flocks.config.config_writer import ConfigWriter + + (temp_project / "config.json").write_text( + json.dumps({"smallModel": "anthropic/claude-haiku"}), + encoding="utf-8", + ) + + assert ConfigWriter.get_fallback_override_source() is None + def test_set_fallback_providers_preserves_small_model(self, temp_project): from flocks.config.config_writer import ConfigWriter diff --git a/tests/server/routes/test_default_model_fallbacks.py b/tests/server/routes/test_default_model_fallbacks.py index 20d10e21f..153b7b71e 100644 --- a/tests/server/routes/test_default_model_fallbacks.py +++ b/tests/server/routes/test_default_model_fallbacks.py @@ -41,6 +41,7 @@ def fallback_route_stubs(monkeypatch: pytest.MonkeyPatch): provider={}, disabled_providers=[], enabled_providers=None, + fallback_providers=[], ) apply_config = AsyncMock() monkeypatch.setattr(default_model_routes, "ConfigWriter", writer) @@ -60,6 +61,7 @@ def fallback_route_stubs(monkeypatch: pytest.MonkeyPatch): ), ) monkeypatch.setattr(Provider, "apply_config", apply_config) + writer.get_fallback_override_source.return_value = None writer.runtime_config = runtime_config writer.apply_config = apply_config return writer @@ -70,8 +72,11 @@ async def test_get_fallbacks_uses_static_route_and_retains_stale_entries( client: AsyncClient, fallback_route_stubs: MagicMock, ): - fallback_route_stubs.get_fallback_providers.return_value = [ - {"provider_id": "removed-provider", "model_id": "vendor/removed-model"}, + fallback_route_stubs.runtime_config.fallback_providers = [ + { + "provider_id": "removed-provider", + "model_id": "vendor/removed-model", + }, ] response = await client.get("/api/default-model/fallbacks") @@ -87,6 +92,48 @@ async def test_get_fallbacks_uses_static_route_and_retains_stale_entries( } +@pytest.mark.asyncio +async def test_get_fallbacks_uses_effective_merged_config( + client: AsyncClient, + fallback_route_stubs: MagicMock, +): + fallback_route_stubs.get_fallback_providers.return_value = [ + {"provider_id": "global", "model_id": "global-model"}, + ] + fallback_route_stubs.runtime_config.fallback_providers = [ + {"provider_id": "inline", "model_id": "effective-model"}, + ] + + response = await client.get("/api/default-model/fallbacks") + + assert response.status_code == 200 + assert response.json() == { + "fallback_providers": [ + {"provider_id": "inline", "model_id": "effective-model"}, + ] + } + fallback_route_stubs.get_fallback_providers.assert_not_called() + + +@pytest.mark.asyncio +async def test_put_fallbacks_rejects_higher_priority_override( + client: AsyncClient, + fallback_route_stubs: MagicMock, +): + fallback_route_stubs.get_fallback_override_source.return_value = ( + "FLOCKS_CONFIG_CONTENT" + ) + + response = await client.put( + "/api/default-model/fallbacks", + json={"fallback_providers": []}, + ) + + assert response.status_code == 409 + assert "FLOCKS_CONFIG_CONTENT" in response.json()["message"] + fallback_route_stubs.set_fallback_providers.assert_not_called() + + @pytest.mark.asyncio async def test_put_fallbacks_normalizes_and_preserves_order( client: AsyncClient, diff --git a/tests/server/routes/test_provider_route_responsiveness.py b/tests/server/routes/test_provider_route_responsiveness.py index ba6967e37..ba1f4a09f 100644 --- a/tests/server/routes/test_provider_route_responsiveness.py +++ b/tests/server/routes/test_provider_route_responsiveness.py @@ -211,3 +211,37 @@ async def _config(): assert heartbeat_ticks >= 3 assert response.all == [] + + +async def test_list_providers_exposes_runtime_credential_state( + monkeypatch: pytest.MonkeyPatch, +): + from types import SimpleNamespace + + from flocks.server.routes import provider as provider_routes + + async def _initialized() -> None: + return None + + async def _config(): + raise RuntimeError("no merged config needed for focused route test") + + runtime_provider = SimpleNamespace( + name="Missing Credentials", + is_configured=lambda: False, + ) + monkeypatch.setattr(provider_routes, "_ensure_provider_initialized", _initialized) + monkeypatch.setattr(provider_routes.Config, "get", _config) + monkeypatch.setattr( + provider_routes.ConfigWriter, + "list_provider_ids", + lambda: ["missing-credentials"], + ) + monkeypatch.setattr(provider_routes.Provider, "get", lambda _provider_id: runtime_provider) + monkeypatch.setattr(provider_routes.Provider, "list_models", lambda _provider_id: []) + + response = await provider_routes.list_providers() + + assert len(response.all) == 1 + assert response.all[0].configured is False + assert response.connected == ["missing-credentials"] diff --git a/tests/session/test_auto_model_failover.py b/tests/session/test_auto_model_failover.py index 8f883db27..058306c65 100644 --- a/tests/session/test_auto_model_failover.py +++ b/tests/session/test_auto_model_failover.py @@ -187,6 +187,131 @@ async def test_runner_applies_failover_retry_thresholds( assert call_llm.await_count == expected_calls +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status_code", "expected_calls"), + [ + (429, 1), + (503, 2), + ], +) +async def test_last_auto_candidate_keeps_hermes_retry_thresholds( + monkeypatch, + status_code: int, + expected_calls: int, +): + """The last candidate still has Auto's per-model retry budget.""" + runner = SessionRunner( + session=_session(), + provider_id="fallback", + model_id="fallback-model", + defer_step_errors=True, + failover_available=False, + ) + last_user = SimpleNamespace(id="msg_user", agent="rex", role="user") + provider = MagicMock() + provider.is_configured.return_value = True + assistant = SimpleNamespace(id="msg_assistant") + failure = RuntimeError(f"Provider HTTP {status_code}") + failure.status_code = status_code + call_llm = AsyncMock(side_effect=failure) + + monkeypatch.setattr( + "flocks.session.runner.Agent.get", + AsyncMock(return_value=SimpleNamespace( + name="rex", + steps=None, + mode="primary", + prompt="", + tools=[], + )), + ) + monkeypatch.setattr("flocks.session.runner.Provider.get", lambda _provider_id: provider) + monkeypatch.setattr("flocks.session.runner.Provider.apply_config", AsyncMock()) + monkeypatch.setattr( + "flocks.session.runner.SessionPrompt.build_system_prompts", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr(runner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr( + runner, + "_to_chat_messages", + AsyncMock(return_value=[SimpleNamespace(role="user", content="hello")]), + ) + monkeypatch.setattr(Message, "get_text_content", AsyncMock(return_value="hello")) + monkeypatch.setattr(Message, "parts", AsyncMock(return_value=[])) + monkeypatch.setattr(Message, "create", AsyncMock(return_value=assistant)) + monkeypatch.setattr(Message, "update", AsyncMock()) + monkeypatch.setattr(runner, "_call_llm", call_llm) + monkeypatch.setattr("flocks.session.runner.SessionRetry.sleep", AsyncMock()) + + result = await runner._process_step([last_user], last_user) + + assert result.failure is not None + assert call_llm.await_count == expected_calls + + +@pytest.mark.parametrize( + ("exception", "status_code", "reason"), + [ + ( + type("GoogleSdkError", (RuntimeError,), {"code": 429})( + "Resource exhausted" + ), + 429, + "rate_limit", + ), + ( + type( + "ResponseSdkError", + (RuntimeError,), + { + "response": SimpleNamespace( + status_code=503, + headers={"retry-after": "1"}, + ) + }, + )("Service unavailable"), + 503, + "overloaded", + ), + ], +) +def test_exception_status_is_normalized_from_sdk_shapes( + exception: Exception, + status_code: int, + reason: str, +): + runner = SessionRunner( + session=_session(), + provider_id="primary", + model_id="primary-model", + ) + + error = runner._exception_to_error_dict(exception) + + assert error["data"]["statusCode"] == status_code + assert SessionRunner.classify_failover_error(error).reason == reason + + +def test_exception_status_is_normalized_from_cause_chain(): + inner = type("GoogleSdkError", (RuntimeError,), {"code": 401})( + "Unauthenticated" + ) + outer = RuntimeError("Provider wrapper failed") + outer.__cause__ = inner + runner = SessionRunner( + session=_session(), + provider_id="primary", + model_id="primary-model", + ) + + error = runner._exception_to_error_dict(outer) + + assert error["data"]["statusCode"] == 401 + assert SessionRunner.classify_failover_error(error).reason == "auth" + + def test_local_validation_error_never_fails_over(): decision = SessionRunner.classify_failover_error({ "name": "ValidationError", @@ -208,6 +333,17 @@ def test_model_not_found_without_status_fails_over(): assert decision.same_model_retries == 0 +def test_content_filter_error_fails_over_immediately(): + decision = SessionRunner.classify_failover_error({ + "name": "BadRequestError", + "data": {"message": "Response blocked by content_filter"}, + }) + + assert decision.eligible is True + assert decision.reason == "content_policy" + assert decision.same_model_retries == 0 + + def test_candidate_switch_keeps_tool_loop_guard_only(): ctx = _ctx() tool_loop_guard = { @@ -472,6 +608,84 @@ async def publish(event, payload): assert any(event == "session.model.fallback" for event, _ in events) +@pytest.mark.asyncio +async def test_queued_user_is_detected_before_replacement_assistant(): + current_user = SimpleNamespace(id="msg_001", role=MessageRole.USER) + queued_user = SimpleNamespace(id="msg_002", role=MessageRole.USER) + replacement_assistant = SimpleNamespace( + id="msg_003", + role=MessageRole.ASSISTANT, + ) + + detected = await SessionLoop._detect_queued_user_message( + "ses_auto", + [current_user, queued_user, replacement_assistant], + current_user.id, + replacement_assistant, + ) + + assert detected is queued_user + + +@pytest.mark.asyncio +async def test_preflight_chain_exhaustion_persists_final_error(monkeypatch): + ctx = _ctx() + last_user = SimpleNamespace( + id="msg_user", + agent="rex", + role=MessageRole.USER, + ) + + async def preflight_failure(_runner, _messages, _last_user): + message = "Provider not configured" + return StepResult( + action="stop", + error=message, + failure=StepFailure( + message=message, + error_data={ + "name": "ProviderUnavailableError", + "data": {"message": message}, + }, + assistant_message_id=None, + reason="provider_unavailable", + allow_fallback=True, + attempt_state=LlmAttemptState(), + attempts=0, + ), + ) + + final_assistant = SimpleNamespace(id="msg_final_error") + create = AsyncMock(return_value=final_assistant) + monkeypatch.setattr(SessionRunner, "_process_step", preflight_failure) + monkeypatch.setattr(Message, "create", create) + + result = await SessionLoop._process_step_with_failover( + ctx, + LoopCallbacks(), + [last_user], + last_user, + ) + + assert result.error == "Provider not configured" + assert result.failure is not None + assert result.failure.assistant_message_id == final_assistant.id + create.assert_awaited_once_with( + session_id="ses_auto", + role=MessageRole.ASSISTANT, + content="", + agent="rex", + model_id="fallback-model", + provider_id="fallback", + parent_id=last_user.id, + error={ + "name": "ProviderUnavailableError", + "data": {"message": "Provider not configured"}, + }, + finish="error", + ) + + @pytest.mark.asyncio async def test_failed_blank_message_deletion_stops_switch(monkeypatch): ctx = _ctx() diff --git a/webui/src/hooks/useProviders.test.tsx b/webui/src/hooks/useProviders.test.tsx index 9a493efb0..cba9d1e27 100644 --- a/webui/src/hooks/useProviders.test.tsx +++ b/webui/src/hooks/useProviders.test.tsx @@ -60,6 +60,29 @@ describe('useProviders', () => { ]); }); + it('uses the backend credential state instead of treating every connected provider as configured', async () => { + listMock.mockResolvedValue({ + data: { + all: [ + { ...makeProvider('openai', { 'gpt-4o': {} }), configured: false }, + { ...makeProvider('deepseek'), configured: true }, + ], + connected: ['openai', 'deepseek'], + }, + }); + + const { result } = renderHook(() => useProviders()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.providers).toMatchObject([ + { id: 'openai', configured: false, category: 'international' }, + { id: 'deepseek', configured: true, category: 'connected' }, + ]); + }); + it('shares provider list requests across concurrent hook instances', async () => { let resolveList: (value: { data: any }) => void = () => {}; listMock.mockReturnValue(new Promise((resolve) => { diff --git a/webui/src/hooks/useProviders.ts b/webui/src/hooks/useProviders.ts index d671e5b4b..e702e0831 100644 --- a/webui/src/hooks/useProviders.ts +++ b/webui/src/hooks/useProviders.ts @@ -31,7 +31,9 @@ function normalizeProvidersPayload(data: unknown): ProvidersResourceData { const connectedSet = new Set(connectedIds); const providers: EnrichedProvider[] = rawProviders.map((provider) => { const modelCount = provider.models ? Object.keys(provider.models).length : 0; - const configured = connectedSet.has(provider.id); + const configured = typeof provider.configured === 'boolean' + ? provider.configured + : connectedSet.has(provider.id); const category: ProviderCategory = configured ? 'connected' : getProviderCategory(provider.id); return { ...provider, diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index 34c782cb3..5d09681e4 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -34,6 +34,8 @@ "remove": "Remove fallback", "add": "Add fallback model", "noModelsToAdd": "No more available models", + "loadFailed": "Failed to load fallback configuration. Nothing can be saved until it is reloaded.", + "retry": "Retry", "close": "Close fallback models", "cancel": "Cancel", "save": "Save", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index 1fa0b3f04..f0dd72313 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -34,6 +34,8 @@ "remove": "移除备用模型", "add": "添加备用模型", "noModelsToAdd": "没有更多可用模型", + "loadFailed": "备用模型配置加载失败,重新加载前不会保存任何更改。", + "retry": "重试", "close": "关闭备用模型配置", "cancel": "取消", "save": "保存", diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 77196a8e5..85da8e6a9 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -463,6 +463,45 @@ describe('ModelPage fallback model editor', () => { }); }); + it('blocks fallback edits until a failed fallback load is retried', async () => { + const user = userEvent.setup(); + mocks.getFallbacks + .mockRejectedValueOnce(new Error('fallback request failed')) + .mockResolvedValueOnce({ + data: { + fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }], + }, + }); + renderWithRouter(); + + await user.click(await screen.findByTitle('dashboard.editFallbackModels')); + expect(await screen.findByText('fallbacks.loadFailed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); + expect(mocks.setFallbacks).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'fallbacks.retry' })); + expect((await screen.findAllByText('MiniMax M3')).length).toBeGreaterThan(0); + expect(mocks.getFallbacks).toHaveBeenCalledTimes(2); + }); + + it('blocks fallback edits until model definitions load successfully', async () => { + const user = userEvent.setup(); + mocks.listDefinitions + .mockResolvedValueOnce({ data: { models, total: models.length } }) + .mockResolvedValueOnce({ data: { models, total: models.length } }) + .mockRejectedValueOnce(new Error('model definitions failed')) + .mockResolvedValueOnce({ data: { models, total: models.length } }); + renderWithRouter(); + + await user.click(await screen.findByTitle('dashboard.editFallbackModels')); + expect(await screen.findByText('fallbacks.loadFailed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'fallbacks.retry' })); + await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); + expect((await screen.findAllByRole('button', { name: /MiniMax M3/i })).length).toBeGreaterThan(1); + }); + it('requires invalid entries to be removed before saving', async () => { const user = userEvent.setup(); mocks.getFallbacks.mockResolvedValue({ diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 988988644..b16344f6a 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -134,6 +134,8 @@ export default function ModelPage() { const [defaultModel, setDefaultModel] = useState<{ provider_id: string; model_id: string } | null>(null); const [showDefaultModelDialog, setShowDefaultModelDialog] = useState(false); const [fallbackModels, setFallbackModels] = useState([]); + const [fallbackModelsLoading, setFallbackModelsLoading] = useState(true); + const [fallbackModelsLoadError, setFallbackModelsLoadError] = useState(null); const [availableRoutingModels, setAvailableRoutingModels] = useState([]); const [showFallbackModelsDialog, setShowFallbackModelsDialog] = useState(false); @@ -158,18 +160,34 @@ export default function ModelPage() { reconnect: { maxRetries: 5, initialDelay: 2000 }, }); + const loadFallbackModels = useCallback(async (): Promise => { + setFallbackModelsLoading(true); + setFallbackModelsLoadError(null); + try { + const response = await defaultModelAPI.getFallbacks(); + setFallbackModels(response.data.fallback_providers || []); + return true; + } catch (loadError) { + setFallbackModelsLoadError( + loadError instanceof Error ? loadError.message : 'Failed to load fallback models', + ); + return false; + } finally { + setFallbackModelsLoading(false); + } + }, []); + // Fetch dashboard data on mount and validate default model useEffect(() => { usageAPI.getSummary().then(r => setUsageStats(r.data)).catch(() => {}); + void loadFallbackModels(); Promise.all([ defaultModelAPI.getResolved().catch(() => ({ data: null })), - modelV2API.listDefinitions({ enabled_only: true }).catch(() => ({ data: { models: [] } })), - defaultModelAPI.getFallbacks().catch(() => ({ data: { fallback_providers: [] } })), - ]).then(([defaultRes, modelsRes, fallbackRes]) => { + modelV2API.listDefinitions({ enabled_only: true }), + ]).then(([defaultRes, modelsRes]) => { const availableModels = modelsRes.data.models || []; setAvailableRoutingModels(availableModels); - setFallbackModels(fallbackRes.data.fallback_providers || []); const dm = defaultRes.data; if (!dm) return; @@ -186,8 +204,10 @@ export default function ModelPage() { defaultModelAPI.delete('llm').catch(() => {}); setDefaultModel(null); } + }).catch(() => { + // Keep the current default model when model definitions cannot be loaded. }); - }, []); + }, [loadFallbackModels]); // Auto-test all configured providers on initial load, with cache (connected: 1h, failed: 5min) const [autoTested, setAutoTested] = useState(false); @@ -665,8 +685,11 @@ export default function ModelPage() { {showFallbackModelsDialog && ( setShowFallbackModelsDialog(false)} onSaved={(models, availableModels) => { setFallbackModels(models); @@ -3084,14 +3107,20 @@ function SetDefaultModelDialog({ function FallbackModelsDialog({ current, + currentLoading, + currentLoadError, primary, providers, + onRetryCurrent, onClose, onSaved, }: { current: FallbackModelRef[]; + currentLoading: boolean; + currentLoadError: string | null; primary: { provider_id: string; model_id: string } | null; providers: EnrichedProvider[]; + onRetryCurrent: () => Promise; onClose: () => void; onSaved: (models: FallbackModelRef[], availableModels: ModelDefinitionV2[]) => void; }) { @@ -3100,16 +3129,43 @@ function FallbackModelsDialog({ const [draft, setDraft] = useState(() => current.map(model => ({ ...model }))); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); + const [modelsLoadError, setModelsLoadError] = useState(null); const [saving, setSaving] = useState(false); const [adding, setAdding] = useState(false); - useEffect(() => { - modelV2API.listDefinitions({ enabled_only: true }) - .then(response => setModels(response.data.models || [])) - .catch(() => setModels([])) - .finally(() => setLoading(false)); + const loadModels = useCallback(async (): Promise => { + setLoading(true); + setModelsLoadError(null); + try { + const response = await modelV2API.listDefinitions({ enabled_only: true }); + setModels(response.data.models || []); + return true; + } catch (loadError) { + setModelsLoadError( + loadError instanceof Error ? loadError.message : 'Failed to load model definitions', + ); + return false; + } finally { + setLoading(false); + } }, []); + useEffect(() => { + void loadModels(); + }, [loadModels]); + + useEffect(() => { + if (currentLoading || currentLoadError) return; + setDraft(current.map(model => ({ ...model }))); + }, [current, currentLoadError, currentLoading]); + + const handleRetryLoad = useCallback(async () => { + await Promise.all([ + currentLoadError ? onRetryCurrent() : Promise.resolve(true), + modelsLoadError ? loadModels() : Promise.resolve(true), + ]); + }, [currentLoadError, loadModels, modelsLoadError, onRetryCurrent]); + const configuredProviderIds = useMemo( () => new Set(providers.filter(provider => provider.configured).map(provider => provider.id)), [providers], @@ -3159,6 +3215,8 @@ function FallbackModelsDialog({ .sort((a, b) => a.providerName.localeCompare(b.providerName)); }, [draftKeys, primary, providerNames, validModels]); const dirty = JSON.stringify(draft) !== JSON.stringify(current); + const loadFailed = Boolean(currentLoadError || modelsLoadError); + const routingDataLoading = currentLoading || loading; const hasInvalidDraft = useMemo( () => draft.some((fallback) => { const key = `${fallback.provider_id}\u0000${fallback.model_id}`; @@ -3199,7 +3257,7 @@ function FallbackModelsDialog({ }; const handleSave = async () => { - if (!dirty || saving || hasInvalidDraft) return; + if (!dirty || saving || routingDataLoading || loadFailed || hasInvalidDraft) return; setSaving(true); try { await defaultModelAPI.setFallbacks(draft); @@ -3245,10 +3303,23 @@ function FallbackModelsDialog({
- {loading ? ( + {routingDataLoading ? (
+ ) : loadFailed ? ( +
+ +

{t('fallbacks.loadFailed')}

+

{currentLoadError || modelsLoadError}

+ +
) : (
{draft.length === 0 ? ( @@ -3384,7 +3455,7 @@ function FallbackModelsDialog({ {open && ( -
+
{t('modelPicker.title')}
{t('modelPicker.hint')}
@@ -466,60 +560,98 @@ export function ChatModelPicker({
{loading ? (
{t('loading')}
- ) : groupedOptions.length > 0 ? ( - groupedOptions.map((group) => ( -
-
- {group.providerName} - - {t('modelPicker.count', { count: group.models.length })} - + ) : ( + <> + {autoOption && ( +
+
-
- {group.models.map((option) => ( -
- - ))} + + ))} +
-
- )) - ) : ( -
{t('modelPicker.empty')}
+ )) : ( +
{t('modelPicker.empty')}
+ )} + )}
diff --git a/webui/src/components/common/EntitySheet.test.tsx b/webui/src/components/common/EntitySheet.test.tsx index ffde9c541..a8a85b394 100644 --- a/webui/src/components/common/EntitySheet.test.tsx +++ b/webui/src/components/common/EntitySheet.test.tsx @@ -200,6 +200,48 @@ describe('EntitySheet', () => { ); }); + it('propagates manually selected Auto mode to session creation and sending', async () => { + const createAndSend = vi.fn().mockResolvedValue('rex-session-auto'); + mockUseSessionChat.mockReturnValue({ + sessionId: null, + loading: false, + error: null, + create: vi.fn().mockResolvedValue(undefined), + createAndSend, + retry: vi.fn().mockResolvedValue(undefined), + reset: vi.fn(), + }); + + render( + +
Form content
+
, + ); + + expect(mockUseSessionChat).toHaveBeenCalledWith(expect.objectContaining({ + category: 'entity-config', + modelAuto: true, + })); + const sessionChatProps = vi.mocked(SessionChat).mock.calls.at(-1)?.[0] as any; + expect(sessionChatProps).toEqual(expect.objectContaining({ + model: null, + modelAuto: true, + })); + + await sessionChatProps.onCreateAndSend('hello', [], undefined, undefined); + expect(createAndSend).toHaveBeenCalledWith(expect.objectContaining({ + text: 'hello', + model: null, + modelAuto: true, + })); + }); + it('renders extract from Rex as a guide action instead of a standalone footer action', async () => { const user = userEvent.setup(); const onExtractFromRex = vi.fn().mockResolvedValue(undefined); @@ -327,6 +369,30 @@ describe('EntitySheet', () => { }); }); + it('restores Auto selection from a persisted Rex session', async () => { + const onRexModelAutoChange = vi.fn(); + window.localStorage.setItem( + 'flocks:entity-sheet:rex-session:v1:agent-edit:auto-agent', + 'persisted-auto-session', + ); + mockClientGet.mockResolvedValueOnce({ data: { model_auto: true } }); + + render( + +
Form content
+
, + ); + + await waitFor(() => { + expect(onRexModelAutoChange).toHaveBeenCalledWith(true); + }); + }); + it('clears a stored Rex session when validation reports it missing', async () => { window.localStorage.setItem( 'flocks:entity-sheet:rex-session:v1:agent-edit:audit-agent', diff --git a/webui/src/components/common/EntitySheet.tsx b/webui/src/components/common/EntitySheet.tsx index 37e39b752..355f73ff0 100644 --- a/webui/src/components/common/EntitySheet.tsx +++ b/webui/src/components/common/EntitySheet.tsx @@ -164,6 +164,8 @@ export interface EntitySheetProps { rexAgentName?: string; rexMentionAgents?: Agent[]; rexModel?: { providerID: string; modelID: string } | null; + rexModelAuto?: boolean; + onRexModelAutoChange?: (enabled: boolean) => void; rexSupportsVision?: boolean | null; rexContextWindowTokens?: number | null; /** Persist and resume the Rex conversation across refreshes when provided. */ @@ -212,6 +214,8 @@ export default function EntitySheet({ rexAgentName, rexMentionAgents, rexModel, + rexModelAuto = false, + onRexModelAutoChange, rexSupportsVision, rexContextWindowTokens, rexSessionStorageKey, @@ -267,6 +271,7 @@ export default function EntitySheet({ } = useSessionChat({ title: `${title} — ${t('entity.rexAssist')}`, category: 'entity-config', + modelAuto: rexModelAuto, contextMessage: rexSystemContext, welcomeMessage: rexWelcomeMessage, initialSessionId: storedRexSessionId, @@ -288,8 +293,9 @@ export default function EntitySheet({ (async () => { try { - await client.get(`/api/session/${stored}`); + const response = await client.get(`/api/session/${stored}`); if (cancelled) return; + onRexModelAutoChange?.(Boolean(response.data?.model_auto)); setStoredRexSessionId(stored); } catch { if (cancelled) return; @@ -305,7 +311,7 @@ export default function EntitySheet({ return () => { cancelled = true; }; - }, [rexSessionStorageKey]); + }, [onRexModelAutoChange, rexSessionStorageKey]); useEffect(() => { if (!rexSessionHydrated || !sessionId) return; @@ -374,6 +380,7 @@ export default function EntitySheet({ useEffect(() => { if (!open) { + onRexModelAutoChange?.(false); setActiveTab(getDefaultTab()); if (!rexSessionStorageKey) { resetRexSession(); @@ -387,7 +394,7 @@ export default function EntitySheet({ setTestPrompt(effectiveDefaultTestPrompt); setDrawerWidth(resolvedInitialWidth()); } - }, [open, mode, defaultTestPrompt, resetRexSession, initialWidth, showTabs, hideRex, hideForm, initialTab, rexSessionStorageKey]); + }, [open, mode, defaultTestPrompt, resetRexSession, initialWidth, showTabs, hideRex, hideForm, initialTab, onRexModelAutoChange, rexSessionStorageKey]); // ── Tab handling ────────────────────────────────────────────────────────── @@ -432,18 +439,15 @@ export default function EntitySheet({ const openRex = useCallback( (msg?: string) => { setActiveTab('rex'); - if (activeRexSessionId && msg) { - const payload: Record = { - parts: [{ type: 'text', text: msg }], - }; - if (rexAgentName) payload.agent = rexAgentName; - if (rexModel) payload.model = rexModel; - client.post(`/api/session/${activeRexSessionId}/prompt_async`, payload); - } else if (msg) { - createAndSendRex({ text: msg, agent: rexAgentName, model: rexModel }).catch(() => {}); - } + if (!msg) return; + createAndSendRex({ + text: msg, + agent: rexAgentName, + model: rexModel, + modelAuto: rexModelAuto, + }).catch(() => {}); }, - [activeRexSessionId, createAndSendRex, rexAgentName, rexModel], + [createAndSendRex, rexAgentName, rexModel, rexModelAuto], ); // ── openTest (exposed via context) ──────────────────────────────────────── @@ -499,9 +503,10 @@ export default function EntitySheet({ text: prompt, agent: rexAgentName, model: rexModel, + modelAuto: rexModelAuto, displayText: buildInstructionDisplayText(label), }).catch(() => {}); - }, [createAndSendRex, handleExtract, rexAgentName, rexModel]); + }, [createAndSendRex, handleExtract, rexAgentName, rexModel, rexModelAuto]); if (!open) return null; @@ -698,6 +703,7 @@ export default function EntitySheet({ agentName={rexAgentName} mentionAgents={rexMentionAgents} model={rexModel} + modelAuto={rexModelAuto} supportsVision={rexSupportsVision ?? supportsVision} contextWindowTokens={rexContextWindowTokens} toolbarSlot={rexToolbarSlot} @@ -709,6 +715,7 @@ export default function EntitySheet({ imageParts, agent: agentOverride || rexAgentName, model: modelOverride === undefined ? rexModel : modelOverride, + modelAuto: rexModelAuto, displayText: options?.displayText, }) : undefined} welcomeContent={( diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index bb9d0f4d6..0d751f570 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -48,6 +48,7 @@ const sessionApiResendMessageMock = vi.fn(); const sessionApiRegenerateMessageMock = vi.fn(); const sessionApiGetContextUsageMock = vi.fn(); const sessionApiGetMock = vi.fn(); +const sessionApiUpdateMock = vi.fn(); const useSessionMessagesMock = vi.fn(); const useSSEOptionsRef = vi.hoisted(() => ({ current: null as any })); const tMock = (key: string, options?: Record) => { @@ -190,6 +191,7 @@ vi.mock('@/api/client', () => ({ vi.mock('@/api/session', () => ({ sessionApi: { get: (...args: unknown[]) => sessionApiGetMock(...args), + update: (...args: unknown[]) => sessionApiUpdateMock(...args), listPromptQueue: (...args: unknown[]) => sessionApiListPromptQueueMock(...args), enqueuePrompt: (...args: unknown[]) => sessionApiEnqueuePromptMock(...args), updateQueuedPrompt: (...args: unknown[]) => sessionApiUpdateQueuedPromptMock(...args), @@ -234,6 +236,7 @@ beforeEach(() => { sessionApiResendMessageMock.mockResolvedValue({}); sessionApiRegenerateMessageMock.mockResolvedValue({}); sessionApiGetMock.mockResolvedValue({}); + sessionApiUpdateMock.mockResolvedValue({}); sessionApiGetContextUsageMock.mockResolvedValue({ sessionID: 'sess-1', usedTokens: 0, @@ -968,6 +971,31 @@ describe('SessionChat instruction display text', () => { }); describe('SessionChat composer controls', () => { + it('enables Auto on an existing session before sending without a model override', async () => { + const user = userEvent.setup(); + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + modelAuto: true, + model: null, + })); + + await user.type(screen.getByPlaceholderText('请输入消息'), 'continue{enter}'); + + await waitFor(() => { + expect(sessionApiUpdateMock).toHaveBeenCalledWith('sess-1', { + model_auto: true, + model_pinned: false, + }); + expect(clientPostMock).toHaveBeenCalledWith( + '/api/session/sess-1/prompt_async', + { parts: [{ type: 'text', text: 'continue' }] }, + ); + }); + expect(sessionApiUpdateMock.mock.invocationCallOrder[0]).toBeLessThan( + clientPostMock.mock.invocationCallOrder[0], + ); + }); + it('keeps the disabled send button visible in dark mode', () => { const { container } = render(React.createElement(SessionChat, { sessionId: 'sess-1' })); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index 29df9c903..5aba58b8f 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -147,6 +147,8 @@ export interface SessionChatProps { agentName?: string; /** Model override to include in prompt_async requests */ model?: { providerID: string; modelID: string } | null; + /** Persist Auto failover before sending through an existing session. */ + modelAuto?: boolean; /** Agents available for one-turn @mention routing. */ mentionAgents?: Agent[]; /** Display configuration (compact, showActions, showTimestamp) */ @@ -1440,6 +1442,7 @@ export default function SessionChat({ initialDisplayText, agentName, model, + modelAuto = false, display, welcomeContent, conversationBottomSlot, @@ -1470,6 +1473,18 @@ export default function SessionChat({ const effectiveComposerTextareaMaxHeight = composerTextareaMaxHeight ?? (compact ? 96 : 200); const effectivePlaceholder = placeholder ?? t('chat.placeholder'); const effectiveEmptyText = emptyText ?? t('chat.emptyText'); + const autoModelSessionRef = useRef(null); + useEffect(() => { + if (!modelAuto) autoModelSessionRef.current = null; + }, [modelAuto]); + const ensureAutoModelSession = useCallback(async () => { + if (!sessionId || !modelAuto || autoModelSessionRef.current === sessionId) return; + await sessionApi.update(sessionId, { + model_auto: true, + model_pinned: false, + }); + autoModelSessionRef.current = sessionId; + }, [modelAuto, sessionId]); // Restore any persisted draft on first mount so navigating away (e.g. // sidebar → Agents → back to Sessions) doesn't wipe the user's half-typed // message. Subsequent session changes are re-hydrated by the effect below. @@ -2428,6 +2443,7 @@ export default function SessionChat({ } as Message); try { + await ensureAutoModelSession(); await client.post(`/api/session/${sessionId}/command`, { command, arguments: args, @@ -2461,6 +2477,7 @@ export default function SessionChat({ options?: PromptDisplayOptions, ) => { if (!sessionId) return; + await ensureAutoModelSession(); const effectiveAgent = agentOverride || agentName; const visibleText = options?.displayText || text; // Clear abort state immediately so SSE events for the new stream are not suppressed @@ -2521,6 +2538,7 @@ export default function SessionChat({ if (!sessionId) return; const effectiveAgent = agentOverride || agentName; try { + await ensureAutoModelSession(); await enqueuePrompt({ parts: buildPromptParts(text, imageParts), ...(effectiveAgent ? { agent: effectiveAgent } : {}), diff --git a/webui/src/components/common/useRexComposerControls.tsx b/webui/src/components/common/useRexComposerControls.tsx index 2a8c602d0..f2bfbf010 100644 --- a/webui/src/components/common/useRexComposerControls.tsx +++ b/webui/src/components/common/useRexComposerControls.tsx @@ -18,17 +18,23 @@ export function useRexComposerControls() { const { groupedOptions, loading, + effectiveModelOption, + modelPickerAutoOption, + selectModelKey, + selectedModelAuto, selectedModelOption, selectedPromptModel, - setSelectedModelKey, - } = useChatModelOptions(); + setSelectedModelAuto, + } = useChatModelOptions({ enableAuto: true }); return useMemo(() => ({ rexAgentName: REX_AGENT_NAME, rexMentionAgents: agents, rexModel: selectedPromptModel, - rexSupportsVision: selectedModelOption?.supportsVision ?? defaultSupportsVision, - rexContextWindowTokens: selectedModelOption?.contextWindowTokens ?? null, + rexModelAuto: selectedModelAuto, + onRexModelAutoChange: setSelectedModelAuto, + rexSupportsVision: effectiveModelOption?.supportsVision ?? defaultSupportsVision, + rexContextWindowTokens: effectiveModelOption?.contextWindowTokens ?? null, rexComposerTextareaMinHeight: 48, rexComposerTextareaMaxHeight: 120, rexToolbarSlot: ( @@ -42,16 +48,20 @@ export function useRexComposerControls() { groupedOptions={groupedOptions} loading={loading} selectedModelOption={selectedModelOption} - onSelectModel={(option) => setSelectedModelKey(option.key)} + onSelectModel={(option) => selectModelKey(option.key)} + autoOption={modelPickerAutoOption} /> ), }), [ agents, defaultSupportsVision, + effectiveModelOption, groupedOptions, loading, + modelPickerAutoOption, + selectModelKey, + selectedModelAuto, selectedModelOption, selectedPromptModel, - setSelectedModelKey, ]); } diff --git a/webui/src/hooks/useChatModelResources.ts b/webui/src/hooks/useChatModelResources.ts index 8442ff46f..cd93f25af 100644 --- a/webui/src/hooks/useChatModelResources.ts +++ b/webui/src/hooks/useChatModelResources.ts @@ -1,5 +1,5 @@ import { defaultModelAPI, modelV2API } from '@/api/provider'; -import type { FallbackModelRef, ModelDefinitionV2 } from '@/types'; +import type { ModelDefinitionV2 } from '@/types'; import { createSharedResource, useSharedResource, @@ -37,17 +37,6 @@ const resolvedDefaultModelResource = createSharedResource( fallbackDataOnError: null, }); -const fallbackModelsResource = createSharedResource({ - initialData: [], - staleTimeMs: CHAT_MODEL_RESOURCES_STALE_TIME_MS, - minFetchIntervalMs: 1000, - fetcher: async () => { - const response = await defaultModelAPI.getFallbacks(); - return response?.data?.fallback_providers ?? []; - }, - fallbackDataOnError: [], -}); - export function useEnabledChatModelDefinitions() { return useSharedResource(enabledModelDefinitionsResource); } @@ -60,12 +49,6 @@ export function useResolvedDefaultModel(enabled: boolean) { }); } -export function useFallbackModels() { - return useSharedResource(fallbackModelsResource, { - silentInitialLoad: true, - }); -} - export function fetchEnabledChatModelDefinitions( options?: SharedResourceFetchOptions, ): Promise { @@ -78,12 +61,7 @@ export function fetchResolvedDefaultModel( return resolvedDefaultModelResource.fetch(options); } -export function invalidateFallbackModels(): void { - fallbackModelsResource.invalidate(); -} - export function __resetChatModelResourcesForTesting(): void { enabledModelDefinitionsResource.resetForTesting(); resolvedDefaultModelResource.resetForTesting(); - fallbackModelsResource.resetForTesting(); } diff --git a/webui/src/hooks/useSessionChat.test.ts b/webui/src/hooks/useSessionChat.test.ts index 74a4698e9..fbab68793 100644 --- a/webui/src/hooks/useSessionChat.test.ts +++ b/webui/src/hooks/useSessionChat.test.ts @@ -13,8 +13,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const mockPost = vi.fn(); +const mockPatch = vi.fn(); vi.mock('@/api/client', () => ({ - default: { post: (...args: unknown[]) => mockPost(...args) }, + default: { + post: (...args: unknown[]) => mockPost(...args), + patch: (...args: unknown[]) => mockPatch(...args), + }, })); import { renderHook, act } from '@testing-library/react'; @@ -25,6 +29,7 @@ const SESSION_ID = 'sess-abc'; beforeEach(() => { vi.clearAllMocks(); + mockPatch.mockResolvedValue({ data: {} }); // /api/session creates a new session mockPost.mockImplementation((url: string) => { if (url === '/api/session') return Promise.resolve({ data: { id: SESSION_ID } }); @@ -139,3 +144,85 @@ describe('useSessionChat.createAndSend — image forwarding', () => { ); }); }); + +describe('useSessionChat — Auto session creation', () => { + it('forwards the hook modelAuto option when creating a session', async () => { + const { result } = renderHook(() => + useSessionChat({ title: 'Auto chat', category: 'entity-config', modelAuto: true }), + ); + + await act(async () => { + await result.current.create(); + }); + + expect(mockPost).toHaveBeenCalledWith('/api/session', { + title: 'Auto chat', + category: 'entity-config', + model_auto: true, + }); + }); + + it('lets create explicitly override the hook modelAuto option', async () => { + const { result } = renderHook(() => + useSessionChat({ title: 'Auto chat', modelAuto: true }), + ); + + await act(async () => { + await result.current.create({ modelAuto: false }); + }); + + expect(mockPost).toHaveBeenCalledWith('/api/session', { + title: 'Auto chat', + model_auto: false, + }); + }); + + it('lets createAndSend enable Auto without adding a synthetic prompt model', async () => { + const { result } = renderHook(() => + useSessionChat({ title: 'Auto chat' }), + ); + + await act(async () => { + await result.current.createAndSend({ + text: 'hello', + model: null, + modelAuto: true, + }); + }); + + expect(mockPost).toHaveBeenCalledWith('/api/session', { + title: 'Auto chat', + model_auto: true, + }); + expect(mockPost).toHaveBeenCalledWith( + `/api/session/${SESSION_ID}/prompt_async`, + { parts: [{ type: 'text', text: 'hello' }] }, + ); + }); + + it('enables Auto before sending through a resumed session', async () => { + const { result } = renderHook(() => + useSessionChat({ + title: 'Auto chat', + initialSessionId: 'existing-session', + modelAuto: true, + }), + ); + + await act(async () => { + await result.current.createAndSend({ text: 'continue' }); + }); + + expect(mockPatch).toHaveBeenCalledWith('/api/session/existing-session', { + model_auto: true, + model_pinned: false, + }); + expect(mockPatch.mock.invocationCallOrder[0]).toBeLessThan( + mockPost.mock.invocationCallOrder[0], + ); + expect(mockPost).toHaveBeenCalledWith( + '/api/session/existing-session/prompt_async', + { parts: [{ type: 'text', text: 'continue' }] }, + ); + }); +}); diff --git a/webui/src/hooks/useSessionChat.ts b/webui/src/hooks/useSessionChat.ts index f936d6cc2..022b75f10 100644 --- a/webui/src/hooks/useSessionChat.ts +++ b/webui/src/hooks/useSessionChat.ts @@ -5,6 +5,8 @@ import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; export interface UseSessionChatOptions { title: string; category?: string; + /** Enable runtime model failover when creating a new session. */ + modelAuto?: boolean; /** Context injected via noReply (not visible as user message) */ contextMessage?: string; /** Mock welcome message from assistant */ @@ -21,12 +23,15 @@ export interface CreateAndSendOptions { imageParts?: ImagePartData[]; agent?: string; model?: { providerID: string; modelID: string } | null; + /** Override Auto mode for the new session created by this send. */ + modelAuto?: boolean; displayText?: string; } export function useSessionChat({ title, category, + modelAuto, contextMessage, welcomeMessage, initialSessionId = null, @@ -38,8 +43,8 @@ export function useSessionChat({ const sessionIdRef = useRef(initialSessionId); const createPromiseRef = useRef | null>(null); - const optionsRef = useRef({ title, category, contextMessage, welcomeMessage }); - optionsRef.current = { title, category, contextMessage, welcomeMessage }; + const optionsRef = useRef({ title, category, modelAuto, contextMessage, welcomeMessage }); + optionsRef.current = { title, category, modelAuto, contextMessage, welcomeMessage }; const create = useCallback( async (overrides?: Partial): Promise => { @@ -53,8 +58,9 @@ export function useSessionChat({ const opts = { ...optionsRef.current, ...overrides }; const doCreate = async (): Promise => { - const payload: Record = { title: opts.title }; + const payload: Record = { title: opts.title }; if (opts.category) payload.category = opts.category; + if (typeof opts.modelAuto === 'boolean') payload.model_auto = opts.modelAuto; const res = await client.post('/api/session', payload); const sid: string = res.data.id; @@ -120,9 +126,22 @@ export function useSessionChat({ imageParts, agent, model, + modelAuto: createModelAuto, displayText, }: CreateAndSendOptions): Promise => { - const sid = await create(); + const resumedExistingSession = Boolean(sessionIdRef.current); + const effectiveModelAuto = typeof createModelAuto === 'boolean' + ? createModelAuto + : optionsRef.current.modelAuto; + const sid = await create( + typeof createModelAuto === 'boolean' ? { modelAuto: createModelAuto } : undefined, + ); + if (resumedExistingSession && effectiveModelAuto) { + await client.patch(`/api/session/${sid}`, { + model_auto: true, + model_pinned: false, + }); + } const payload: Record = { parts: buildPromptParts(text, imageParts), }; diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index 5d09681e4..367976e2a 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -9,10 +9,6 @@ "setDefaultModel": "Set Default Model", "defaultModelUpdated": "Default model updated", "defaultModelInvalid": "Default model \"{{model}}\" is no longer available and has been cleared. Please select a new one.", - "fallbackModels": "Fallback Models", - "noFallbackModels": "Not set", - "fallbackAvailability": "{{available}} / {{total}} available", - "editFallbackModels": "Edit fallback models", "connected": "Connected Provider", "availableModels": "Available Models", "totalUsage": "Total Usage", @@ -22,24 +18,11 @@ "noCost": "No cost", "toggleCurrency": "Click to switch between USD and CNY" }, - "fallbacks": { - "title": "Fallback Models", - "description": "Auto sessions try these models in order when the primary model fails.", - "empty": "No fallback models configured", - "emptyHint": "Add at least one available model to enable Auto in WebUI chats.", - "unavailable": "Unavailable", - "removeInvalidHint": "Remove retired, disabled, or primary-model entries before saving.", - "moveUp": "Move up", - "moveDown": "Move down", - "remove": "Remove fallback", - "add": "Add fallback model", - "noModelsToAdd": "No more available models", - "loadFailed": "Failed to load fallback configuration. Nothing can be saved until it is reloaded.", - "retry": "Retry", - "close": "Close fallback models", - "cancel": "Cancel", - "save": "Save", - "saved": "Fallback models updated" + "modelSelection": { + "info": "Model information", + "free": "Free", + "unavailable": "Not provided", + "closeDefault": "Close default model selector" }, "providerList": { "empty": "No providers configured", diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index a5ca400f9..b2719e4e3 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -60,8 +60,9 @@ "hint": "Overrides the model used when sending this chat message", "empty": "No available models", "auto": "Auto", - "autoHint": "Use the primary model, then fail over in order if it fails", - "autoUnavailable": "Configure a primary model and at least one available fallback", + "autoHint": "If the primary model fails, randomly try up to one available model from the same provider, then one from another provider", + "autoUnavailable": "Configure an available primary model", + "autoUserSessionsOnly": "Auto is available only for WebUI chat sessions", "count": "{{count}}", "vision": "Vision", "free": "Free", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index f0dd72313..aef45a275 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -9,10 +9,6 @@ "setDefaultModel": "设置默认模型", "defaultModelUpdated": "默认模型已更新", "defaultModelInvalid": "当前默认模型「{{model}}」已不在可用列表中,已自动清除,请重新选择", - "fallbackModels": "备用模型", - "noFallbackModels": "未设置", - "fallbackAvailability": "{{available}} / {{total}} 可用", - "editFallbackModels": "编辑备用模型", "connected": "已连接 Provider", "availableModels": "可用模型", "totalUsage": "总用量", @@ -22,24 +18,11 @@ "noCost": "暂无费用", "toggleCurrency": "点击切换人民币 / 美元" }, - "fallbacks": { - "title": "备用模型", - "description": "Auto 会话在主模型失败后,会按此处顺序切换模型。", - "empty": "尚未配置备用模型", - "emptyHint": "添加至少一个可用模型后,WebUI 对话才能选择 Auto。", - "unavailable": "不可用", - "removeInvalidHint": "保存前请先移除已停用、已删除或与主模型重复的条目。", - "moveUp": "上移", - "moveDown": "下移", - "remove": "移除备用模型", - "add": "添加备用模型", - "noModelsToAdd": "没有更多可用模型", - "loadFailed": "备用模型配置加载失败,重新加载前不会保存任何更改。", - "retry": "重试", - "close": "关闭备用模型配置", - "cancel": "取消", - "save": "保存", - "saved": "备用模型已更新" + "modelSelection": { + "info": "模型信息", + "free": "免费", + "unavailable": "暂无", + "closeDefault": "关闭默认模型选择" }, "providerList": { "empty": "尚未添加模型供应商", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 72513db30..78e7c9274 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -60,8 +60,9 @@ "hint": "作为本次对话发送时的模型覆盖", "empty": "暂无可用模型", "auto": "Auto", - "autoHint": "优先使用主模型,失败时按顺序切换备用模型", - "autoUnavailable": "请先配置主模型和至少一个可用的备用模型", + "autoHint": "主模型失败后,依次随机尝试同 Provider 和其他 Provider 的可用模型(各最多一个)", + "autoUnavailable": "请先配置可用的主模型", + "autoUserSessionsOnly": "Auto 仅适用于 WebUI 对话会话", "count": "{{count}} 个", "vision": "视觉", "free": "免费", diff --git a/webui/src/pages/DeviceIntegration/index.tsx b/webui/src/pages/DeviceIntegration/index.tsx index 121c388ec..061ac9646 100644 --- a/webui/src/pages/DeviceIntegration/index.tsx +++ b/webui/src/pages/DeviceIntegration/index.tsx @@ -578,8 +578,9 @@ function DeviceAddRexPanel({ text: prompt, agent: rexComposerControls.rexAgentName, model: rexComposerControls.rexModel, + modelAuto: rexComposerControls.rexModelAuto, }).catch(() => {}); - }, [createAndSend, rexComposerControls.rexAgentName, rexComposerControls.rexModel]); + }, [createAndSend, rexComposerControls.rexAgentName, rexComposerControls.rexModel, rexComposerControls.rexModelAuto]); const vendorGroups = useMemo(() => { const groups = new Map(); @@ -719,6 +720,7 @@ function DeviceAddRexPanel({ agentName={rexComposerControls.rexAgentName} mentionAgents={rexComposerControls.rexMentionAgents} model={rexComposerControls.rexModel} + modelAuto={rexComposerControls.rexModelAuto} supportsVision={rexComposerControls.rexSupportsVision} contextWindowTokens={rexComposerControls.rexContextWindowTokens} composerTextareaMinHeight={rexComposerControls.rexComposerTextareaMinHeight} @@ -879,6 +881,7 @@ function DeviceAddRexPanel({ imageParts, agent: agentOverride || rexComposerControls.rexAgentName, model: modelOverride === undefined ? rexComposerControls.rexModel : modelOverride, + modelAuto: rexComposerControls.rexModelAuto, }) : undefined} /> @@ -1985,6 +1988,7 @@ export default function DeviceIntegrationPage() { } = useSessionChat({ title: t('wizard.rex.title'), category: 'entity-config', + modelAuto: rexComposerControls.rexModelAuto, contextMessage: rexContextMessage, welcomeMessage: t('wizard.rex.welcome'), }); @@ -2221,6 +2225,7 @@ export default function DeviceIntegrationPage() { ...buildDeviceTestGuidePrompt(createdDevice, panel.template), agent: rexComposerControls.rexAgentName, model: rexComposerControls.rexModel, + modelAuto: rexComposerControls.rexModelAuto, }).catch(() => {}); pollRexTestStatus(createdDevice); await fetchData(true); @@ -2258,12 +2263,13 @@ export default function DeviceIntegrationPage() { ...prompt, agent: rexComposerControls.rexAgentName, model: rexComposerControls.rexModel, + modelAuto: rexComposerControls.rexModelAuto, }); if (input.action === 'test' && input.device) { pollRexTestStatus(input.device); } setPanel({ kind: 'wizard' }); - }, [createAndSendRex, pollRexTestStatus, rexComposerControls.rexAgentName, rexComposerControls.rexModel]); + }, [createAndSendRex, pollRexTestStatus, rexComposerControls.rexAgentName, rexComposerControls.rexModel, rexComposerControls.rexModelAuto]); // ────────────────────────────────────────────────────────────────────────── // Group to use when adding a new device (follows sidebar selection). diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 85da8e6a9..5291bc6a9 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { vi } from 'vitest'; @@ -17,8 +17,6 @@ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), getSummary: vi.fn(), getResolved: vi.fn(), - getFallbacks: vi.fn(), - setFallbacks: vi.fn(), listDefinitions: vi.fn(), catalogList: vi.fn(), createProvider: vi.fn(), @@ -32,8 +30,8 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, params?: Record) => { if (key === 'status.models') return `${params?.count ?? 0} models`; - if (key === 'dashboard.fallbackAvailability') { - return `${params?.available ?? 0} / ${params?.total ?? 0} available`; + if (key === 'modelSelection.info') { + return 'modelSelection.info'; } const translations: Record = { pageTitle: 'Models', @@ -143,8 +141,6 @@ vi.mock('@/api/provider', () => ({ }, defaultModelAPI: { getResolved: mocks.getResolved, - getFallbacks: mocks.getFallbacks, - setFallbacks: mocks.setFallbacks, delete: vi.fn(), set: vi.fn(), }, @@ -163,8 +159,6 @@ describe('ModelPage add provider dialog', () => { }); mocks.getSummary.mockResolvedValue({ data: null }); mocks.getResolved.mockResolvedValue({ data: null }); - mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); - mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); mocks.listDefinitions.mockResolvedValue({ data: { models: [] } }); mocks.catalogList.mockResolvedValue({ data: { @@ -273,8 +267,6 @@ describe('ModelPage configure provider dialog', () => { }); mocks.getSummary.mockResolvedValue({ data: null }); mocks.getResolved.mockResolvedValue({ data: null }); - mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); - mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); mocks.listDefinitions.mockResolvedValue({ data: { models: [model], total: 1 } }); mocks.catalogList.mockResolvedValue({ data: { @@ -380,11 +372,11 @@ describe('ModelPage configure provider dialog', () => { }); }); -describe('ModelPage fallback model editor', () => { +describe('ModelPage default model selector', () => { const providers = [ { id: 'openai', - name: 'OpenAI', + name: 'OpenAI Gateway', source: 'config', env: [], key: null, @@ -396,7 +388,7 @@ describe('ModelPage fallback model editor', () => { }, { id: 'minimax', - name: 'MiniMax', + name: 'MiniMax Cloud', source: 'config', env: [], key: null, @@ -418,11 +410,19 @@ describe('ModelPage fallback model editor', () => { }, { id: 'minimax-m3', - name: 'MiniMax M3', + name: 'MiniMax Vision M3', provider_id: 'minimax', model_type: 'llm', status: 'active', - capabilities: { features: [], supports_streaming: true, supports_tools: true }, + capabilities: { + features: [], + supports_streaming: true, + supports_tools: true, + supports_vision: false, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + limits: { context_window: 200000, max_output_tokens: 8192 }, + pricing: { input: 1.25, output: 5, unit: 1000000, currency: 'USD' }, }, ]; @@ -439,141 +439,39 @@ describe('ModelPage fallback model editor', () => { }); mocks.getSummary.mockResolvedValue({ data: null }); mocks.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); - mocks.getFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); - mocks.setFallbacks.mockResolvedValue({ data: { fallback_providers: [] } }); mocks.listDefinitions.mockResolvedValue({ data: { models, total: models.length } }); mocks.getCredentials.mockResolvedValue({ data: null }); mocks.testCredentials.mockResolvedValue({ data: { success: true, latency_ms: 10 } }); }); - it('adds and explicitly saves an ordered fallback list', async () => { + it('shows provider groups, model identity, vision, and details', async () => { const user = userEvent.setup(); renderWithRouter(); - await user.click(await screen.findByTitle('dashboard.editFallbackModels')); - await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); - const matchingModels = await screen.findAllByRole('button', { name: /MiniMax M3/i }); - await user.click(matchingModels[matchingModels.length - 1]); - await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); - - await waitFor(() => { - expect(mocks.setFallbacks).toHaveBeenCalledWith([ - { provider_id: 'minimax', model_id: 'minimax-m3' }, - ]); - }); - }); + await user.click(await screen.findByTitle('dashboard.setDefaultModel')); + const heading = await screen.findByRole('heading', { name: 'dashboard.setDefaultModel' }); + const selector = within(heading.parentElement?.parentElement as HTMLElement); - it('blocks fallback edits until a failed fallback load is retried', async () => { - const user = userEvent.setup(); - mocks.getFallbacks - .mockRejectedValueOnce(new Error('fallback request failed')) - .mockResolvedValueOnce({ - data: { - fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }], - }, - }); - renderWithRouter(); - - await user.click(await screen.findByTitle('dashboard.editFallbackModels')); - expect(await screen.findByText('fallbacks.loadFailed')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); - expect(mocks.setFallbacks).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'fallbacks.retry' })); - expect((await screen.findAllByText('MiniMax M3')).length).toBeGreaterThan(0); - expect(mocks.getFallbacks).toHaveBeenCalledTimes(2); - }); - - it('blocks fallback edits until model definitions load successfully', async () => { - const user = userEvent.setup(); - mocks.listDefinitions - .mockResolvedValueOnce({ data: { models, total: models.length } }) - .mockResolvedValueOnce({ data: { models, total: models.length } }) - .mockRejectedValueOnce(new Error('model definitions failed')) - .mockResolvedValueOnce({ data: { models, total: models.length } }); - renderWithRouter(); + expect(selector.getByText('MiniMax Cloud')).toBeInTheDocument(); + expect(selector.getByText('MiniMax Vision M3')).toBeInTheDocument(); + expect(selector.getByText('minimax-m3')).toBeInTheDocument(); + expect(selector.getByText('form.vision')).toBeInTheDocument(); + expect(selector.getByRole('button', { + name: 'modelSelection.info GPT-4o', + })).toBeInTheDocument(); - await user.click(await screen.findByTitle('dashboard.editFallbackModels')); - expect(await screen.findByText('fallbacks.loadFailed')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); - - await user.click(screen.getByRole('button', { name: 'fallbacks.retry' })); - await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); - expect((await screen.findAllByRole('button', { name: /MiniMax M3/i })).length).toBeGreaterThan(1); - }); - - it('requires invalid entries to be removed before saving', async () => { - const user = userEvent.setup(); - mocks.getFallbacks.mockResolvedValue({ - data: { - fallback_providers: [ - { provider_id: 'missing', model_id: 'retired-model' }, - { provider_id: 'minimax', model_id: 'minimax-m3' }, - ], - }, - }); - renderWithRouter(); - - await user.click(await screen.findByTitle('dashboard.editFallbackModels')); - expect(await screen.findByText('fallbacks.unavailable')).toBeInTheDocument(); - expect(screen.getByText('fallbacks.removeInvalidHint')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeDisabled(); - - await user.click(screen.getAllByRole('button', { name: 'fallbacks.remove' })[0]); - expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeEnabled(); - await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); - - await waitFor(() => { - expect(mocks.setFallbacks).toHaveBeenCalledWith([ - { provider_id: 'minimax', model_id: 'minimax-m3' }, - ]); - }); - }); - - it('does not count a fallback from an unconfigured provider as available', async () => { - const user = userEvent.setup(); - mocks.useProviders.mockReturnValue({ - providers: [providers[0], { ...providers[1], configured: false }], - connectedIds: ['openai'], - loading: false, - error: null, - refetch: mocks.refetch, - }); - mocks.getFallbacks.mockResolvedValue({ - data: { - fallback_providers: [{ provider_id: 'minimax', model_id: 'minimax-m3' }], - }, - }); - renderWithRouter(); - - expect(await screen.findByText('0 / 1 available')).toBeInTheDocument(); - await user.click(screen.getByTitle('dashboard.editFallbackModels')); - expect(await screen.findByText('fallbacks.unavailable')).toBeInTheDocument(); - }); - - it('allows an unconfigured provider model to be saved for later repair', async () => { - const user = userEvent.setup(); - mocks.useProviders.mockReturnValue({ - providers: [providers[0], { ...providers[1], configured: false }], - connectedIds: ['openai'], - loading: false, - error: null, - refetch: mocks.refetch, - }); - renderWithRouter(); - - await user.click(await screen.findByTitle('dashboard.editFallbackModels')); - await user.click(await screen.findByRole('button', { name: 'fallbacks.add' })); - const matchingModels = await screen.findAllByRole('button', { name: /MiniMax M3/i }); - await user.click(matchingModels[matchingModels.length - 1]); - expect(screen.getByText('fallbacks.unavailable')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'fallbacks.save' })).toBeEnabled(); - await user.click(screen.getByRole('button', { name: 'fallbacks.save' })); + await user.click(selector.getByRole('button', { + name: 'modelSelection.info MiniMax Vision M3', + })); - await waitFor(() => { - expect(mocks.setFallbacks).toHaveBeenCalledWith([ - { provider_id: 'minimax', model_id: 'minimax-m3' }, - ]); - }); + const tooltip = await screen.findByRole('tooltip'); + expect(within(tooltip).getByText('form.modelId')).toBeInTheDocument(); + expect(within(tooltip).getByText('form.contextWindow')).toBeInTheDocument(); + expect(within(tooltip).getByText('form.pricing')).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('minimax-m3'); + expect(tooltip).toHaveTextContent(/200(?:K|,?000)/); + expect(tooltip).toHaveTextContent(/1\.25/); + expect(tooltip).toHaveTextContent(/\b5(?:\.0+)?\b/); + expect(tooltip).toHaveTextContent(/USD|\$/); }); }); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index b16344f6a..0cc9b8481 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react'; +import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Brain, Cog, TestTube, Trash2, Search, @@ -7,7 +8,8 @@ import { Plus, ToggleLeft, ToggleRight, ChevronDown, Check, AlertCircle, Loader2, X, Shield, Pencil, Star, AlertTriangle, - CheckCircle2, ArrowUp, ArrowDown, ListOrdered, + CheckCircle2, + Info, } from 'lucide-react'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; @@ -17,7 +19,6 @@ import EntitySheet from '@/components/common/EntitySheet'; import { useProviders, type EnrichedProvider } from '@/hooks/useProviders'; import { useSSE } from '@/hooks/useSSE'; import { MODEL_CHANGED_EVENT } from '@/hooks/useDefaultModelVision'; -import { invalidateFallbackModels } from '@/hooks/useChatModelResources'; import { providerAPI, modelV2API, usageAPI, customAPI, modelSettingsAPI, catalogAPI, defaultModelAPI, @@ -33,7 +34,6 @@ import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, CustomModelCreate, ProviderCredentialInput, - FallbackModelRef, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -133,11 +133,6 @@ export default function ModelPage() { const [usageStats, setUsageStats] = useState(null); const [defaultModel, setDefaultModel] = useState<{ provider_id: string; model_id: string } | null>(null); const [showDefaultModelDialog, setShowDefaultModelDialog] = useState(false); - const [fallbackModels, setFallbackModels] = useState([]); - const [fallbackModelsLoading, setFallbackModelsLoading] = useState(true); - const [fallbackModelsLoadError, setFallbackModelsLoadError] = useState(null); - const [availableRoutingModels, setAvailableRoutingModels] = useState([]); - const [showFallbackModelsDialog, setShowFallbackModelsDialog] = useState(false); // Refs for latest handler/state values (avoid stale closures in SSE & one-time effects) const sseRefetchTimer = useRef(null); @@ -160,34 +155,15 @@ export default function ModelPage() { reconnect: { maxRetries: 5, initialDelay: 2000 }, }); - const loadFallbackModels = useCallback(async (): Promise => { - setFallbackModelsLoading(true); - setFallbackModelsLoadError(null); - try { - const response = await defaultModelAPI.getFallbacks(); - setFallbackModels(response.data.fallback_providers || []); - return true; - } catch (loadError) { - setFallbackModelsLoadError( - loadError instanceof Error ? loadError.message : 'Failed to load fallback models', - ); - return false; - } finally { - setFallbackModelsLoading(false); - } - }, []); - // Fetch dashboard data on mount and validate default model useEffect(() => { usageAPI.getSummary().then(r => setUsageStats(r.data)).catch(() => {}); - void loadFallbackModels(); Promise.all([ defaultModelAPI.getResolved().catch(() => ({ data: null })), modelV2API.listDefinitions({ enabled_only: true }), ]).then(([defaultRes, modelsRes]) => { const availableModels = modelsRes.data.models || []; - setAvailableRoutingModels(availableModels); const dm = defaultRes.data; if (!dm) return; @@ -207,7 +183,7 @@ export default function ModelPage() { }).catch(() => { // Keep the current default model when model definitions cannot be loaded. }); - }, [loadFallbackModels]); + }, []); // Auto-test all configured providers on initial load, with cache (connected: 1h, failed: 5min) const [autoTested, setAutoTested] = useState(false); @@ -231,22 +207,6 @@ export default function ModelPage() { [configuredProviders, connectionStatus] ); - const availableFallbackCount = useMemo(() => { - const configuredProviderIds = new Set(configuredProviders.map(provider => provider.id)); - const availableKeys = new Set( - availableRoutingModels - .filter(model => model.model_type === 'llm') - .map(model => `${model.provider_id}\u0000${model.id}`), - ); - return fallbackModels.filter(model => ( - configuredProviderIds.has(model.provider_id) - && availableKeys.has(`${model.provider_id}\u0000${model.model_id}`) - && !(defaultModel - && model.provider_id === defaultModel.provider_id - && model.model_id === defaultModel.model_id) - )).length; - }, [availableRoutingModels, configuredProviders, defaultModel, fallbackModels]); - // Auto-select last-used provider (persisted in sessionStorage), fallback to first const autoSelectedRef = useRef(false); useEffect(() => { @@ -517,9 +477,6 @@ export default function ModelPage() { usageStats={usageStats} defaultModel={defaultModel} onEditDefault={() => setShowDefaultModelDialog(true)} - fallbackCount={fallbackModels.length} - availableFallbackCount={availableFallbackCount} - onEditFallbacks={() => setShowFallbackModelsDialog(true)} /> {/* Main Content: Provider List + Detail Panel */} @@ -671,6 +628,7 @@ export default function ModelPage() { {showDefaultModelDialog && ( setShowDefaultModelDialog(false)} onSaved={(m) => { setDefaultModel(m); @@ -682,22 +640,6 @@ export default function ModelPage() { /> )} - {showFallbackModelsDialog && ( - setShowFallbackModelsDialog(false)} - onSaved={(models, availableModels) => { - setFallbackModels(models); - setAvailableRoutingModels(availableModels); - setShowFallbackModelsDialog(false); - }} - /> - )}
); } @@ -710,18 +652,12 @@ function DashboardStrip({ usageStats, defaultModel, onEditDefault, - fallbackCount, - availableFallbackCount, - onEditFallbacks, }: { connectedCount: number; totalModels: number; usageStats: UsageStats | null; defaultModel: { provider_id: string; model_id: string } | null; onEditDefault: () => void; - fallbackCount: number; - availableFallbackCount: number; - onEditFallbacks: () => void; }) { const { t, i18n } = useTranslation('model'); const totalTokens = usageStats?.summary?.total_tokens ?? 0; @@ -737,7 +673,7 @@ function DashboardStrip({ }, [i18n.language]); return ( -
+
{/* Default Model Card */}
@@ -760,17 +696,6 @@ function DashboardStrip({
{defaultModel.provider_id}
)}
- } - label={t('dashboard.fallbackModels')} - value={fallbackCount > 0 - ? t('dashboard.fallbackAvailability', { available: availableFallbackCount, total: fallbackCount }) - : t('dashboard.noFallbackModels')} - color="purple" - small={fallbackCount > 0} - onClick={onEditFallbacks} - title={t('dashboard.editFallbackModels')} - /> } label={t('dashboard.connected')} value={String(connectedCount)} color="green" /> } label={t('dashboard.availableModels')} value={String(totalModels)} color="blue" /> , +): ModelSelectionGroup[] { + const grouped = new Map(); + models.forEach(model => { + const entries = grouped.get(model.provider_id) ?? []; + entries.push(model); + grouped.set(model.provider_id, entries); + }); + return Array.from(grouped.entries()) + .map(([providerId, providerModels]) => ({ + providerId, + providerName: providerNames.get(providerId) || providerId, + models: providerModels.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)), + })) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); +} + +function formatModelContextWindow(model: ModelDefinitionV2): string { + const contextWindow = model.limits?.context_window; + if (!contextWindow) return '—'; + if (contextWindow >= 1_000_000) { + return `${Number((contextWindow / 1_000_000).toFixed(1))}M`; + } + if (contextWindow >= 1_000) { + return `${Number((contextWindow / 1_000).toFixed(1))}K`; + } + return String(contextWindow); +} + +function formatModelPricing( + model: ModelDefinitionV2, + freeLabel: string, + unavailableLabel: string, +): string { + const pricing = model.pricing; + if (!pricing) return unavailableLabel; + if (pricing.input === 0 && pricing.output === 0) return freeLabel; + const symbol = pricing.currency === 'CNY' ? '¥' : pricing.currency === 'USD' ? '$' : `${pricing.currency} `; + return `${symbol}${pricing.input} / ${symbol}${pricing.output} / 1M`; +} + +function ModelSelectionInfo({ model }: { model: ModelDefinitionV2 }) { + const { t } = useTranslation('model'); + const tooltipId = useId(); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + const show = useCallback((target: HTMLElement) => { + const rect = target.getBoundingClientRect(); + const tooltipHalfWidth = 128; + const viewportPadding = 8; + setPosition({ + x: Math.min( + window.innerWidth - tooltipHalfWidth - viewportPadding, + Math.max(tooltipHalfWidth + viewportPadding, rect.left + rect.width / 2), + ), + y: rect.top - 8, + }); + }, []); + const hide = useCallback(() => setPosition(null), []); + const label = `${t('modelSelection.info')} ${model.name || model.id}`; + + return ( + <> + + {position && typeof document !== 'undefined' && createPortal( +