Newer
Older
navi-1 / tests / unit / llm / test_model_stamping.py
"""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