diff --git a/clients/terminal/api.py b/clients/terminal/api.py index 400214b..b7a9b92 100644 --- a/clients/terminal/api.py +++ b/clients/terminal/api.py @@ -18,6 +18,30 @@ return resp.json() +def get_profile(profile_id: str) -> dict | None: + """Return the profile dict for ``profile_id`` from /agents/profiles, or None.""" + for p in get_profiles(): + if p.get("id") == profile_id: + return p + return None + + +def get_profile_model(profile_id: str) -> str | None: + """Configured model for a profile (first item of its priority list), or None. + + Used to show a model in the status panel before the first request resolves + the actually-served model. ``profile.model`` is a priority list; the first + entry is the intended model. + """ + profile = get_profile(profile_id) + if not profile: + return None + model = profile.get("model") + if isinstance(model, list): + return model[0] if model else None + return model or None + + def list_sessions() -> list[dict]: with _client() as client: resp = client.get("/sessions") diff --git a/clients/terminal/render.py b/clients/terminal/render.py index 8fb00b4..46520f8 100644 --- a/clients/terminal/render.py +++ b/clients/terminal/render.py @@ -113,5 +113,12 @@ self._print(f"[/{title}]", color="cyan") return + if msg_type == "model_info": + if self.show_events: + model = msg.get("model", "") or "" + if model: + self._print(f"[model] {model}", color="bright_black") + return + if self.show_events: self._print(f"[event: {msg_type}] {msg}", color="bright_black") diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 9488786..c26d433 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -168,10 +168,18 @@ self._status_panel.set_session(session_id) self._status_panel.set_profile(self._ctx.profile_id) + # Show the profile's configured model (first of its priority list) so the + # panel has a value before the first request resolves the actually-served + # model via a model_info event. Falls back to the global default, then "-". + configured = None + try: + configured = api.get_profile_model(self._ctx.profile_id) + except Exception: + configured = None self._status_panel.set_model( - settings.ollama_default_model - if hasattr(settings, "ollama_default_model") - else "unknown" + configured + or (settings.ollama_default_model if hasattr(settings, "ollama_default_model") else None) + or "-" ) self._status_panel.set_backend(settings.base_url) self._status_panel.set_theme(self._theme_name) @@ -331,6 +339,14 @@ self._input_box.set_placeholder("Ask anything...") self._input_box.focus_input() self._update_footer_bindings() + elif msg_type == "model_info": + # The model that actually served this turn (resolved by the backend, + # may differ from the configured priority list when a model was down). + # Update the status panel; not a chat item, so don't forward it. + model = payload.get("model") + if model: + self._status_panel.set_model(model) + return if msg_type == "tool_started": tool = payload.get("tool", "") diff --git a/navi/core/agent.py b/navi/core/agent.py index 6e52620..ec2d498 100644 --- a/navi/core/agent.py +++ b/navi/core/agent.py @@ -57,6 +57,7 @@ AgentEvent, AIHelperTokensUsed, CompressionStarted, + ModelInfo, PlanningDebugData, StreamEnd, StreamStopped, @@ -832,6 +833,13 @@ state.context_tokens = chunk.prompt_tokens if chunk.completion_tokens is not None: turn_ctx.turn_tokens += chunk.completion_tokens + # Surface the model that actually served this turn to the UI. The + # backend stamps it on the first successful chunk (fallback may pick + # a different model than the profile's first priority). Re-emit only + # when it changes across iterations within the same turn. + if chunk.model and chunk.model != turn_ctx.resolved_model: + turn_ctx.resolved_model = chunk.model + yield ModelInfo(model=chunk.model) if chunk.thinking: state.accumulated_thinking += chunk.thinking if not state.thinking_active: diff --git a/navi/core/agent_run_context.py b/navi/core/agent_run_context.py index 77b1209..a3b08ee 100644 --- a/navi/core/agent_run_context.py +++ b/navi/core/agent_run_context.py @@ -25,6 +25,10 @@ turn_tokens: int = 0 subagent_tokens: int = 0 injected_fact_ids: set[str] = field(default_factory=set) + # Last model reported to the UI via a ModelInfo event this turn. Tracked + # across iterations so we only re-emit when the fallback backend actually + # switches to a different model mid-turn. + resolved_model: str | None = None @dataclass diff --git a/navi/core/events.py b/navi/core/events.py index b8a5ca9..2f90374 100644 --- a/navi/core/events.py +++ b/navi/core/events.py @@ -109,6 +109,23 @@ @dataclass +class ModelInfo: + """Reports which model actually served the current turn. + + Emitted once per turn after the backend resolves a model (on the first + successful streaming chunk). Lets the UI show the "currently served" + model — which may differ from the profile's configured priority list when a + model/server was down and the fallback backend picked another. Emitted + after stream_start; additive (older clients ignore the unknown type). + """ + + model: str + + def to_wire(self) -> dict: + return {"type": "model_info", "model": self.model} + + +@dataclass class CompressionStarted: """Emitted immediately before context compression begins. diff --git a/navi/llm/base.py b/navi/llm/base.py index 67e2aa8..f6faa6f 100644 --- a/navi/llm/base.py +++ b/navi/llm/base.py @@ -77,6 +77,11 @@ thinking: str | None = None prompt_tokens: int | None = None completion_tokens: int | None = None + # Which model actually served this call. Backends that resolve a single model + # (Ollama/OpenAI) echo the requested model; the fallback backend reports the + # model that survived its server+model priority list. Used to surface the + # "currently served" model to the UI. + model: str | None = None class LLMChunk(BaseModel): @@ -90,6 +95,10 @@ # Token counts — only present on the final chunk prompt_tokens: int | None = None completion_tokens: int | None = None + # Which model actually served this stream. Set on the first chunk the backend + # yields after a successful model resolution (see fallback backend). The agent + # reads it to emit a ModelInfo event so the UI can show the resolved model. + model: str | None = None class LLMBackend(ABC): diff --git a/navi/llm/fallback.py b/navi/llm/fallback.py index 49e0b29..d091573 100644 --- a/navi/llm/fallback.py +++ b/navi/llm/fallback.py @@ -146,11 +146,16 @@ if _is_dead_model(server.host, m): continue try: - return await self._get_client(server).complete( + resp = await self._get_client(server).complete( messages, tools=tools, temperature=temperature, model=m, think=think, max_tokens=max_tokens, top_k=top_k, top_p=top_p, num_thread=num_thread, ) + # Record which model actually served (m is the resolved model + # from the priority list that survived on this server). + if resp.model is None: + resp.model = m + return resp except LLMConnectionError as e: log.warning("fallback.server_dead", host=server.host, error=str(e)) last_err = e @@ -267,6 +272,10 @@ last_err = e break else: + # Stamp the resolved model onto the first chunk so the + # agent can surface it to the UI via a ModelInfo event. + if first.model is None: + first = first.model_copy(update={"model": m}) yield first async for chunk in gen: yield chunk diff --git a/navi/llm/ollama.py b/navi/llm/ollama.py index e63446e..4ffe6c4 100644 --- a/navi/llm/ollama.py +++ b/navi/llm/ollama.py @@ -156,6 +156,7 @@ thinking=getattr(msg, "thinking", None) or None, prompt_tokens=getattr(response, "prompt_eval_count", None), completion_tokens=getattr(response, "eval_count", None), + model=resolved, ) except (LLMConnectionError, LLMModelNotFoundError, LLMBackendError): raise @@ -194,6 +195,7 @@ if tools: kwargs["tools"] = _to_ollama_tools(tools) + first_chunk = True async for chunk in await self._client.chat(**kwargs): thinking = getattr(chunk.message, "thinking", None) or None delta = chunk.message.content or None @@ -220,7 +222,11 @@ tool_calls=tool_calls, prompt_tokens=chunk.prompt_eval_count if chunk.done else None, completion_tokens=chunk.eval_count if chunk.done else None, + # Stamp the resolved model on the first chunk only so the agent + # can surface it via a ModelInfo event without per-chunk overhead. + model=resolved if first_chunk else None, ) + first_chunk = False except (LLMConnectionError, LLMModelNotFoundError, LLMBackendError): raise except Exception as e: diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index f37e971..6b8982d 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -46,9 +46,13 @@ def fake_list_sessions() -> list[dict]: return [] + def fake_get_profile_model(profile_id: str) -> str | None: + return "configured-model" + monkeypatch.setattr(api_module, "create_session", fake_create_session) monkeypatch.setattr(api_module, "get_session", fake_get_session) monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions) + monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model) @pytest.mark.anyio @@ -324,6 +328,43 @@ @pytest.mark.anyio +async def test_attach_shows_configured_model() -> None: + """On attach the status panel shows the profile's configured model (pre-request).""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + status = pilot.app.query_one("StatusPanel") + model_text = str(status._model.render()) + assert "configured-model" in model_text + + +@pytest.mark.anyio +async def test_model_info_updates_status_panel() -> None: + """A model_info WS event updates the status panel to the resolved model.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + app = pilot.app + app.on_ws_event(WsEvent({"type": "model_info", "model": "resolved-by-backend"})) + await pilot.pause() + status = app.query_one("StatusPanel") + model_text = str(status._model.render()) + assert "resolved-by-backend" in model_text + + +@pytest.mark.anyio +async def test_model_info_not_forwarded_to_chat() -> None: + """model_info is a status update, not a chat item.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + app = pilot.app + app.on_ws_event(WsEvent({"type": "model_info", "model": "some-model"})) + await pilot.pause() + chat = app.query_one("ChatPanel") + assert not any( + getattr(item, "kind", None) == "model_info" for item in chat._model.items + ) + + +@pytest.mark.anyio async def test_streaming_state_tracks_ws_events() -> None: """stream_start enters streaming mode; stream_end/stream_stopped/error leave it.""" async with NaviCodeTui(new_session=True).run_test() as pilot: diff --git a/tests/unit/core/test_agent.py b/tests/unit/core/test_agent.py index ce3a1c2..3e70857 100644 --- a/tests/unit/core/test_agent.py +++ b/tests/unit/core/test_agent.py @@ -11,6 +11,7 @@ from navi.core.agent import Agent, _is_casual_message from navi.core.events import ( + ModelInfo, StreamEnd, StreamStopped, SubagentComplete, @@ -161,6 +162,76 @@ assert saved.messages[-1].content == "streamed hello" @pytest.mark.asyncio + async def test_run_stream_emits_model_info(self, agent, session): + """The agent emits a ModelInfo event carrying the resolved model.""" + from typing import AsyncGenerator + + from navi.llm.base import LLMBackend + + class ModelStampingBackend(LLMBackend): + async def complete(self, messages, tools=None, temperature=0.7, model=None, + think=None, max_tokens=None, **kw): + raise NotImplementedError + + async def stream_complete(self, messages, tools=None, temperature=0.7, + model=None, think=None, **kw) -> AsyncGenerator[LLMChunk, None]: + yield LLMChunk(delta="hello", model="resolved-model") + yield LLMChunk(finish_reason="stop", prompt_tokens=5, completion_tokens=1) + + async def embed(self, texts, model=None): + return [[0.1] * 768 for _ in texts] + + agent._backends.register("ollama", ModelStampingBackend()) + events = [] + async for ev in agent.run_stream(session.id, "hi"): + events.append(ev) + + infos = [ev for ev in events if isinstance(ev, ModelInfo)] + assert len(infos) == 1 + assert infos[0].model == "resolved-model" + + @pytest.mark.asyncio + async def test_run_stream_emits_model_info_once_per_turn(self, agent, session): + """ModelInfo is not re-emitted across iterations if the model stays the same.""" + from typing import AsyncGenerator + + from navi.llm.base import LLMBackend + + class ModelStampingBackend(LLMBackend): + def __init__(self): + self._call = 0 + + async def complete(self, messages, tools=None, temperature=0.7, model=None, + think=None, max_tokens=None, **kw): + raise NotImplementedError + + async def stream_complete(self, messages, tools=None, temperature=0.7, + model=None, think=None, **kw) -> AsyncGenerator[LLMChunk, None]: + self._call += 1 + if self._call == 1: + yield LLMChunk( + delta="", model="same-model", + finish_reason="tool_calls", + tool_calls=[ToolCallRequest(id="1", name="test_tool", arguments={})], + ) + else: + yield LLMChunk(delta="final answer", model="same-model") + yield LLMChunk(finish_reason="stop", prompt_tokens=5, completion_tokens=1) + + async def embed(self, texts, model=None): + return [[0.1] * 768 for _ in texts] + + agent._backends.register("ollama", ModelStampingBackend()) + events = [] + async for ev in agent.run_stream(session.id, "do something"): + events.append(ev) + + # Two iterations happened (tool call then final), but ModelInfo fires once. + infos = [ev for ev in events if isinstance(ev, ModelInfo)] + assert len(infos) == 1 + assert infos[0].model == "same-model" + + @pytest.mark.asyncio async def test_run_stream_tool_calls(self, agent, session): backend = FakeLLMBackend( responses=["", "final"], diff --git a/tests/unit/llm/test_model_stamping.py b/tests/unit/llm/test_model_stamping.py new file mode 100644 index 0000000..4c6cf2b --- /dev/null +++ b/tests/unit/llm/test_model_stamping.py @@ -0,0 +1,142 @@ +"""Tests for the resolved-model stamping on LLMChunk/LLMResponse. + +The fallback backend resolves a single model from the profile's priority list +(trying each until one succeeds) and must surface which model actually served +the call so the UI can show it. We verify the resolved model is stamped onto the +first streaming chunk and onto the non-streaming response. +""" + +import asyncio + +from navi.llm.base import LLMChunk, LLMResponse +from navi.llm.fallback import FallbackOllamaBackend, ServerEntry + + +def _make_backend(monkeypatch, streaming_gen, complete_resp): + """Build a FallbackOllamaBackend whose inner OllamaBackend is faked. + + The fake records the model it was asked to use and returns ``streaming_gen`` + / ``complete_resp``. This lets us assert the fallback stamps the resolved + model that survived the priority list. + """ + import navi.llm.fallback as fallback_mod + + fallback_mod.clear_blacklists() + + used_models: list[str] = [] + + class FakeOllamaBackend: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def complete(self, *args, model=None, **kwargs): + used_models.append(model) + if isinstance(complete_resp, Exception): + raise complete_resp + return complete_resp + + async def stream_complete(self, *args, model=None, **kwargs): + used_models.append(model) + if isinstance(streaming_gen, Exception): + raise streaming_gen + for chunk in streaming_gen: + yield chunk + + monkeypatch.setattr(fallback_mod, "OllamaBackend", FakeOllamaBackend) + backend = FallbackOllamaBackend([ServerEntry(host="http://ollama.test")]) + return backend, used_models + + +def test_fallback_stamps_resolved_model_on_response(monkeypatch): + """complete() returns an LLMResponse carrying the resolved model name.""" + resp = LLMResponse(content="ok", tool_calls=None, finish_reason="stop") + backend, used = _make_backend(monkeypatch, streaming_gen=[], complete_resp=resp) + + result = asyncio.run( + backend.complete([], model=["first-choice", "second-choice"]) + ) + + assert used == ["first-choice"] + assert result.model == "first-choice" + + +def test_fallback_falls_back_and_stamps_serving_model(monkeypatch): + """When the first model is not found, the second serves and is stamped.""" + import navi.llm.fallback as fallback_mod + + fallback_mod.clear_blacklists() + + served: list[str] = [] + + class FakeOllamaBackend: + def __init__(self, **kwargs): + pass + + async def complete(self, *args, model=None, **kwargs): + served.append(model) + if model == "down-model": + raise fallback_mod.LLMModelNotFoundError("missing") + return LLMResponse(content="ok", tool_calls=None, finish_reason="stop") + + monkeypatch.setattr(fallback_mod, "OllamaBackend", FakeOllamaBackend) + backend = FallbackOllamaBackend([ServerEntry(host="http://ollama.test")]) + + result = asyncio.run( + backend.complete([], model=["down-model", "alive-model"]) + ) + + assert served == ["down-model", "alive-model"] + assert result.model == "alive-model" + + +def test_fallback_stamps_resolved_model_on_first_chunk(monkeypatch): + """stream_complete() stamps the resolved model onto the first chunk only.""" + chunks = [ + LLMChunk(delta="hello"), + LLMChunk(delta=" world"), + LLMChunk(delta=None, finish_reason="stop", prompt_tokens=5, completion_tokens=2), + ] + backend, used = _make_backend(monkeypatch, streaming_gen=chunks, complete_resp=None) + + out = [] + async def collect(): + async for chunk in backend.stream_complete([], model=["the-model"]): + out.append(chunk) + asyncio.run(collect()) + + assert used == ["the-model"] + assert len(out) == 3 + assert out[0].model == "the-model" + assert out[1].model is None + assert out[2].model is None + + +def test_ollama_backend_stamps_first_chunk_only(monkeypatch): + """OllamaBackend stamps the resolved model on the first chunk, not later ones.""" + from navi.llm.ollama import OllamaBackend + + class FakeChunk: + def __init__(self, content, done=False): + self.message = type("M", (), {"content": content, "thinking": None, "tool_calls": None})() + self.done = done + self.prompt_eval_count = 5 if done else None + self.eval_count = 2 if done else None + + class FakeClient: + async def chat(self, **kwargs): + async def _gen(): + yield FakeChunk("hi") + yield FakeChunk(" there", done=True) + return _gen() + + backend = OllamaBackend(model="default-model", host="http://ollama.test", api_key="") + monkeypatch.setattr(backend, "_client", FakeClient()) + + out = [] + async def collect(): + async for chunk in backend.stream_complete([], model=["configured-model"]): + out.append(chunk) + asyncio.run(collect()) + + assert out[0].model == "configured-model" + assert out[1].model is None \ No newline at end of file