diff --git a/docs/config.md b/docs/config.md index 50aa479..1ec5ffd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -255,10 +255,18 @@ last good one is used (a new publish clears the block). **Hot vs restart.** Secrets (`GNAUTH_*`, synapse source), timeouts, tool -policies and profiles are applied to the running process. Anything built at -startup (backends, the MCP pool, CORS, the push service) is reported as -`restart_required` in `GET /health` → `config` and in the hive's ack view; -it takes effect on the next start. +policies and profiles are applied to the running process. The `llm` section is +re-parsed and swapped into the live backend (targets, keys, routes, context +windows), so a rotated Ollama key takes effect without a restart. The MCP pool +is built with the container and stays `restart_required`, as does anything +else built at startup (CORS, the push service, the dedicated embedding +backend); those are reported in `GET /health` → `config` and in the hive's ack +view, and take effect on the next start. + +**API keys.** A target may name its key as `api_key_env` (the key stays in +each machine's `.env`) or carry the literal `api_key` — the latter is what the +hive is for: one publish rotates the key for the fleet. A literal key is +preferred over the env name when both are present. **Trust.** The hive is a LAN service. The swarm key is the fleet's read credential and therefore the ability to read config secrets; publishing, diff --git a/navi/llm/fallback.py b/navi/llm/fallback.py index 6f2f72e..11d7ee7 100644 --- a/navi/llm/fallback.py +++ b/navi/llm/fallback.py @@ -98,6 +98,20 @@ def health(self) -> RouteHealth: return self._health + def reconfigure(self, config: LLMConfig) -> None: + """Swap the routing config under a running process. + + A central key or address change arrives while the process is up. The + per-target clients bake host and Authorization header at construction + and ``_active`` is an index into the old route, so both are dropped; + the health state is keyed by target name and survives, so a target + that was cooling is not reset by an unrelated edit. + """ + self._config = config + self._clients.clear() + self._active.clear() + self._health.reconfigure(config) + def start(self) -> None: self._health.start() diff --git a/navi/llm/health.py b/navi/llm/health.py index 52519ab..2752030 100644 --- a/navi/llm/health.py +++ b/navi/llm/health.py @@ -72,6 +72,11 @@ # lets the container log a recovery without importing logging here. self.on_change = None + def reconfigure(self, config: LLMConfig) -> None: + """Follow a config swap: new hosts/keys, same health by target name.""" + self._config = config + self._clients.clear() # probe clients bake host and headers too + # ── queries ────────────────────────────────────────────────────────── def _target_state(self, target: Target) -> TargetHealth: diff --git a/navi/overlay/apply.py b/navi/overlay/apply.py index 3fc969e..8b686c2 100644 --- a/navi/overlay/apply.py +++ b/navi/overlay/apply.py @@ -171,6 +171,50 @@ return merge_patch(raw or {}, patch) # type: ignore[return-value] +def apply_llm_live(base_settings, patch: dict) -> bool: + """Rebuild the LLM routing config inside the running backend. + + A rotated key should not cost a fleet restart — that defeats the point of + a central key. The backend bakes host and Authorization header into its + clients, so it is handed a freshly parsed config and drops them; the next + call builds new ones. + + ``False`` means "not applied" — a backend without the swap method, or a + document that no longer parses — and the caller reports a restart. + """ + from navi.llm.config import load_llm_raw, parse_llm_config + + try: + local_raw = load_llm_raw(base_settings.llm_config_file) or {} + config = parse_llm_config(merge_patch(local_raw, patch or {}), source="hive-overlay") + except Exception as exc: + log.warning( + "hive.config.llm_rebuild_failed", error=f"{type(exc).__name__}: {exc}" + ) + return False + + try: + from navi.api.deps import get_backend_registry + + backend = get_backend_registry().get("ollama") + except Exception: + return False + reconfigure = getattr(backend, "reconfigure", None) + if not callable(reconfigure): + return False + try: + reconfigure(config) + except Exception: + log.warning("hive.config.llm_reconfigure_failed", exc_info=True) + return False + log.info( + "hive.config.llm_applied", + targets=sorted(config.targets), + routes=sorted(config.routes), + ) + return True + + def overlay_mcp_configs(configs: dict) -> dict: patch = (get_state().merged or {}).get("mcp_servers") if patch is None: @@ -190,11 +234,11 @@ def _staged_sections(previous: dict, merged: dict) -> list[str]: - """Sections that changed but only take effect at the next start. + """Sections that changed and are not guaranteed to apply live. - ``llm`` and ``mcp_servers`` are consumed while the container is built — - backends and the MCP pool do not survive a swap — so a refresh that - changes them must say "restart", not quietly report the document applied. + ``llm`` is tried live by ``apply_llm_live`` and only lands here when the + running backend cannot be swapped; ``mcp_servers`` are consumed while the + container is built, so the MCP pool has no live path and always stages. ``profiles`` are rebuilt live and settings are diffed separately. """ staged = [] @@ -259,8 +303,13 @@ # compares against the *previous* merged document to know which fields # were handed back to local values, so it must run before we replace # ``state.merged``. + previous = state.merged or {} hot, restart = apply_settings_live(validation.merged.get("settings") or {}) - restart += _staged_sections(state.merged or {}, validation.merged) + staged = _staged_sections(previous, validation.merged) + if "llm" in staged and apply_llm_live(base, validation.merged.get("llm") or {}): + staged.remove("llm") + hot = sorted([*hot, "llm"]) + restart += staged state.merged = validation.merged apply_profiles_registry_from_state() diff --git a/tests/unit/llm/test_fallback.py b/tests/unit/llm/test_fallback.py index 345ee71..0098403 100644 --- a/tests/unit/llm/test_fallback.py +++ b/tests/unit/llm/test_fallback.py @@ -98,6 +98,35 @@ return [Message(role="user", content="hi")] +class TestReconfigure: + def test_a_new_config_replaces_targets_and_drops_clients(self): + old = Target("cloud", "https://ollama.com", "k-old") + new = Target("cloud", "https://ollama.com", "k-new") + backend = FallbackOllamaBackend( + LLMConfig( + targets={"cloud": old}, + routes={"default": Route("default", (RouteEntry("m", old),))}, + ) + ) + backend._clients["cloud"] = object() # type: ignore[assignment] + backend._health._clients["cloud"] = object() # type: ignore[assignment] + backend._active["default"] = 0 + + config = LLMConfig( + targets={"cloud": new}, + routes={"default": Route("default", (RouteEntry("m", new),))}, + ) + backend.reconfigure(config) + + assert backend.config is config + assert backend.config.targets["cloud"].api_key == "k-new" + # Host and Authorization header are baked into clients — rebuilt lazily. + assert backend._clients == {} + assert backend._active == {} + assert backend.health._config is config + assert backend.health._clients == {} + + class TestFailover: async def test_a_dead_primary_moves_to_the_backup_without_retrying(self): """The old engine retried per server *and* per model: 2 × 90 s. diff --git a/tests/unit/overlay/test_apply.py b/tests/unit/overlay/test_apply.py index deb2727..f1e3eec 100644 --- a/tests/unit/overlay/test_apply.py +++ b/tests/unit/overlay/test_apply.py @@ -252,16 +252,31 @@ assert state.last_error is None -def test_an_llm_change_is_staged_not_claimed_applied(state, monkeypatch): +def _fake_registry(monkeypatch, backend): + import navi.api.deps as deps + + class Registry: + def get(self, name): + return backend if name == "ollama" else None + + monkeypatch.setattr(deps, "get_backend_registry", lambda: Registry()) + + +def _llm_only_fetch(monkeypatch, interval: int): monkeypatch.setattr( apply_mod, "fetch_layers", lambda b: Fetched( version=2, - layers=[{"name": "g", "config": {"llm": {"probe": {"interval_sec": 30}}}}], + layers=[{"name": "g", "config": {"llm": {"probe": {"interval_sec": interval}}}}], hash="sha256:llm", ), ) + + +def test_an_llm_change_is_staged_when_the_backend_cannot_swap(state, monkeypatch): + _fake_registry(monkeypatch, object()) + _llm_only_fetch(monkeypatch, 30) result = refresh("test") assert result["status"] == "staged" assert "llm" in result["restart_required"] @@ -269,6 +284,22 @@ assert state.merged["llm"]["probe"]["interval_sec"] == 30 +def test_an_llm_change_applies_live_when_the_backend_can_swap(state, monkeypatch): + seen = {} + + class Backend: + def reconfigure(self, config): + seen["config"] = config + + _fake_registry(monkeypatch, Backend()) + _llm_only_fetch(monkeypatch, 15) + result = refresh("test") + assert result["status"] == "applied" + assert "llm" in result["hot_applied"] + assert result["restart_required"] == [] + assert seen["config"].probe_interval_sec == 15 + + def test_a_settings_only_refresh_does_not_stage_llm(state, monkeypatch): monkeypatch.setattr( apply_mod,