"""``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_any_variable_name_resolves_from_the_dotenv_file(tmp_path, monkeypatch):
"""A second cloud account's key has no Settings field — only a name in
.env. Hard-coding the two known names left it empty and every call 401."""
monkeypatch.delenv("OLLAMA_API_KEY_2", raising=False)
(tmp_path / ".env").write_text("OLLAMA_API_KEY_2=second-account\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
payload = {
"targets": {"cloud2": {"host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY_2"}},
"routes": {"default": [{"model": "m", "target": "cloud2"}]},
}
config = load_llm_config(_write(tmp_path, payload))
assert config.targets["cloud2"].api_key == "second-account"
def test_the_environment_wins_over_the_dotenv_file(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("TEST_LLM_KEY=from-file\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
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_a_malformed_dotenv_line_does_not_stop_the_lookup(tmp_path, monkeypatch):
"""Production's .env has one such line; it must not hide the keys below."""
monkeypatch.delenv("OLLAMA_API_KEY_2", raising=False)
(tmp_path / ".env").write_text(
"ljap nonsense\nOLLAMA_API_KEY_2=still-found\n", encoding="utf-8"
)
monkeypatch.chdir(tmp_path)
payload = {
"targets": {"cloud2": {"host": "https://ollama.com", "api_key_env": "OLLAMA_API_KEY_2"}},
"routes": {"default": [{"model": "m", "target": "cloud2"}]},
}
config = load_llm_config(_write(tmp_path, payload))
assert config.targets["cloud2"].api_key == "still-found"
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.chdir(tmp_path) # no .env here: the file must not answer
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 == ""