Newer
Older
navi-1 / tests / unit / llm / test_config.py
"""``llm.json`` is the one place that says which model runs where.

The loader is deliberately strict: a *missing* file means "no routes, use the
legacy single host", but a *broken* one must stop startup — silently routing a
turn to the wrong host is worse than refusing to serve it.
"""

import json

import pytest

from navi.llm.config import (
    LLMConfigError,
    config_from_host,
    load_llm_config,
    resolve_config_path,
)

BASE = {
    "targets": {
        "cloud": {"host": "https://ollama.com", "api_key_env": "TEST_LLM_KEY"},
        "home": {"host": "http://192.168.1.129:11434"},
    },
    "routes": {
        "default": [
            {"model": "glm-5.3-flash:cloud", "target": "cloud"},
            {
                "model": "qwen3.6:35b",
                "target": "home",
                "context_window": 49152,
                "options": {"presence_penalty": 0.0},
            },
        ]
    },
    "probe": {"interval_sec": 30, "timeout_sec": 3},
}


def _write(tmp_path, payload):
    path = tmp_path / "llm.json"
    path.write_text(json.dumps(payload), encoding="utf-8")
    return path


# ── happy path ───────────────────────────────────────────────────────────────

def test_loads_targets_routes_and_probe_settings(tmp_path, monkeypatch):
    monkeypatch.setenv("TEST_LLM_KEY", "secret")
    config = load_llm_config(_write(tmp_path, BASE))

    assert set(config.targets) == {"cloud", "home"}
    assert config.targets["cloud"].api_key == "secret"
    assert config.probe_interval_sec == 30
    assert config.probe_timeout_sec == 3
    assert config.source.endswith("llm.json")


def test_a_route_is_an_ordered_chain_with_the_first_entry_as_primary(tmp_path):
    config = load_llm_config(_write(tmp_path, BASE))
    route = config.route("default")

    assert [e.model for e in route.entries] == ["glm-5.3-flash:cloud", "qwen3.6:35b"]
    assert route.primary.model == "glm-5.3-flash:cloud"
    assert route.primary.target.name == "cloud"
    assert config.route_ids() == ["default"]


def test_per_entry_context_window_and_options_are_carried(tmp_path):
    config = load_llm_config(_write(tmp_path, BASE))
    fallback = config.route("default").entries[1]

    assert fallback.context_window == 49152
    assert fallback.options == {"presence_penalty": 0.0}
    assert config.route("default").primary.context_window is None


def test_a_trailing_slash_is_stripped_from_the_host(tmp_path):
    payload = {
        "targets": {"home": {"host": "http://192.168.1.129:11434/"}},
        "routes": {"default": [{"model": "m", "target": "home"}]},
    }
    config = load_llm_config(_write(tmp_path, payload))

    assert config.targets["home"].host == "http://192.168.1.129:11434"


def test_the_defaults_cover_a_file_without_a_probe_block(tmp_path):
    payload = {
        "targets": {"home": {"host": "http://x"}},
        "routes": {"default": [{"model": "m", "target": "home"}]},
    }
    config = load_llm_config(_write(tmp_path, payload))

    assert config.probe_interval_sec == 60
    assert config.probe_timeout_sec == 5


# ── secrets ──────────────────────────────────────────────────────────────────

def test_an_api_key_resolves_from_the_environment(tmp_path, monkeypatch):
    monkeypatch.setenv("TEST_LLM_KEY", "from-env")
    config = load_llm_config(_write(tmp_path, BASE))

    assert config.targets["cloud"].api_key == "from-env"


def test_an_api_key_falls_back_to_settings_when_the_env_is_not_exported(
    tmp_path, monkeypatch
):
    """pydantic-settings reads .env into the settings object, not os.environ."""
    import navi.config as config_mod

    monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
    monkeypatch.setattr(
        config_mod, "settings", config_mod.Settings(_env_file=None, ollama_api_key="from-dotenv")
    )
    payload = {
        "targets": {"cloud": {"host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY"}},
        "routes": {"default": [{"model": "m", "target": "cloud"}]},
    }
    config = load_llm_config(_write(tmp_path, payload))

    assert config.targets["cloud"].api_key == "from-dotenv"


def test_a_literal_api_key_is_used_as_is(tmp_path):
    payload = {
        "targets": {"cloud": {"host": "https://ollama.com", "api_key": "literal"}},
        "routes": {"default": [{"model": "m", "target": "cloud"}]},
    }
    config = load_llm_config(_write(tmp_path, payload))

    assert config.targets["cloud"].api_key == "literal"


def test_a_local_target_needs_no_key(tmp_path):
    config = load_llm_config(_write(tmp_path, BASE))

    assert config.targets["home"].api_key == ""


# ── absent / disabled ────────────────────────────────────────────────────────

def test_a_missing_file_is_not_an_error(tmp_path):
    assert load_llm_config(tmp_path / "nope.json") is None


def test_an_empty_path_disables_routing(tmp_path):
    """``LLM_CONFIG_FILE=""`` is the explicit opt-out; it must not resolve to "."."""
    assert load_llm_config("") is None


# ── broken files ─────────────────────────────────────────────────────────────

def test_broken_json_is_an_error(tmp_path):
    path = tmp_path / "llm.json"
    path.write_text("{ not json", encoding="utf-8")

    with pytest.raises(LLMConfigError, match="invalid JSON"):
        load_llm_config(path)


def test_a_non_object_root_is_an_error(tmp_path):
    with pytest.raises(LLMConfigError, match="expected a JSON object"):
        load_llm_config(_write(tmp_path, ["nope"]))


def test_targets_are_required(tmp_path):
    with pytest.raises(LLMConfigError, match="'targets'"):
        load_llm_config(_write(tmp_path, {"routes": {}}))


def test_a_target_without_a_host_is_an_error(tmp_path):
    payload = {
        "targets": {"home": {}},
        "routes": {"default": [{"model": "m", "target": "home"}]},
    }

    with pytest.raises(LLMConfigError, match="needs a 'host'"):
        load_llm_config(_write(tmp_path, payload))


def test_routes_are_required(tmp_path):
    payload = {"targets": {"home": {"host": "http://x"}}}

    with pytest.raises(LLMConfigError, match="'routes'"):
        load_llm_config(_write(tmp_path, payload))


def test_an_empty_route_is_an_error(tmp_path):
    payload = {"targets": {"home": {"host": "http://x"}}, "routes": {"default": []}}

    with pytest.raises(LLMConfigError, match="non-empty list"):
        load_llm_config(_write(tmp_path, payload))


def test_a_route_entry_without_a_model_is_an_error(tmp_path):
    payload = {
        "targets": {"home": {"host": "http://x"}},
        "routes": {"default": [{"target": "home"}]},
    }

    with pytest.raises(LLMConfigError, match="needs a 'model'"):
        load_llm_config(_write(tmp_path, payload))


def test_an_unknown_target_is_an_error(tmp_path):
    payload = {
        "targets": {"home": {"host": "http://x"}},
        "routes": {"default": [{"model": "m", "target": "nowhere"}]},
    }

    with pytest.raises(LLMConfigError, match="unknown target 'nowhere'"):
        load_llm_config(_write(tmp_path, payload))


# ── lookups used by callers that only know a model name ─────────────────────

def test_a_model_name_finds_the_entry_that_serves_it(tmp_path):
    config = load_llm_config(_write(tmp_path, BASE))

    entry = config.entry_for_model("qwen3.6:35b")

    assert entry.target.name == "home"
    assert config.entry_for_model("never-heard-of-it") is None


def test_an_unknown_model_falls_back_to_the_first_target(tmp_path):
    config = load_llm_config(_write(tmp_path, BASE))

    entry = config.fallback_entry("some-model")

    assert entry.model == "some-model"
    assert entry.target is config.targets["cloud"]


# ── path resolution ─────────────────────────────────────────────────────────

def test_a_relative_path_resolves_from_the_working_directory(tmp_path, monkeypatch):
    path = _write(tmp_path, BASE)
    monkeypatch.chdir(tmp_path)

    assert resolve_config_path("llm.json").resolve() == path.resolve()


def test_an_absolute_path_is_used_verbatim(tmp_path):
    path = _write(tmp_path, BASE)

    assert resolve_config_path(str(path)) == path


# ── legacy single-target config ─────────────────────────────────────────────

def test_config_from_host_has_one_target_and_no_routes():
    config = config_from_host("http://192.168.1.129:11434/", api_key="k")

    assert config.routes == {}
    assert config.route("default") is None
    assert config.targets["default"].host == "http://192.168.1.129:11434"
    assert config.targets["default"].api_key == "k"
    assert config.source == ""