From f62a9d990150eaf12b128f2c1b97fbc0188a87ad Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 4 Apr 2026 18:36:29 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20llm=5Frouter=20shim=20=E2=80=94=20tri-l?= =?UTF-8?q?evel=20config=20priority=20(local=20>=20user=20>=20env-var)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/llm_router.py | 35 +++++++-- tests/test_llm_router_shim.py | 132 ++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/test_llm_router_shim.py diff --git a/scripts/llm_router.py b/scripts/llm_router.py index 45f9fc1..b88bed5 100644 --- a/scripts/llm_router.py +++ b/scripts/llm_router.py @@ -1,19 +1,46 @@ """ LLM abstraction layer with priority fallback chain. -Reads config/llm.yaml. Tries backends in order; falls back on any error. +Config lookup order: + 1. /config/llm.yaml — per-install local config + 2. ~/.config/circuitforge/llm.yaml — user-level config (circuitforge-core default) + 3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST, …) """ from pathlib import Path from circuitforge_core.llm import LLMRouter as _CoreLLMRouter +# Kept for backwards-compatibility — external callers that import CONFIG_PATH +# from this module continue to work. CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml" class LLMRouter(_CoreLLMRouter): - """Peregrine-specific LLMRouter — defaults to Peregrine's config/llm.yaml.""" + """Peregrine-specific LLMRouter — tri-level config path priority. - def __init__(self, config_path: Path = CONFIG_PATH): - super().__init__(config_path) + When ``config_path`` is supplied (e.g. in tests) it is passed straight + through to the core. When omitted, the lookup order is: + 1. /config/llm.yaml (per-install local config) + 2. ~/.config/circuitforge/llm.yaml (user-level, circuitforge-core default) + 3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST …) + """ + + def __init__(self, config_path: Path | None = None) -> None: + if config_path is not None: + # Explicit path supplied — use it directly (e.g. tests, CLI override). + super().__init__(config_path) + return + + local = Path(__file__).parent.parent / "config" / "llm.yaml" + user_level = Path.home() / ".config" / "circuitforge" / "llm.yaml" + if local.exists(): + super().__init__(local) + elif user_level.exists(): + super().__init__(user_level) + else: + # No yaml found — let circuitforge-core's env-var auto-config run. + # The core default CONFIG_PATH (~/.config/circuitforge/llm.yaml) + # won't exist either, so _auto_config_from_env() will be triggered. + super().__init__() # Module-level singleton for convenience diff --git a/tests/test_llm_router_shim.py b/tests/test_llm_router_shim.py new file mode 100644 index 0000000..23866a0 --- /dev/null +++ b/tests/test_llm_router_shim.py @@ -0,0 +1,132 @@ +"""Tests for Peregrine's LLMRouter shim — priority fallback logic.""" +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock, call + +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def _import_fresh(): + """Import scripts.llm_router fresh (bypass module cache).""" + import importlib + import scripts.llm_router as mod + importlib.reload(mod) + return mod + + +# --------------------------------------------------------------------------- +# Test 1: local config/llm.yaml takes priority when it exists +# --------------------------------------------------------------------------- + +def test_uses_local_yaml_when_present(): + """When config/llm.yaml exists locally, super().__init__ is called with that path.""" + import scripts.llm_router as shim_mod + from circuitforge_core.llm import LLMRouter as _CoreLLMRouter + + local_path = Path(shim_mod.__file__).parent.parent / "config" / "llm.yaml" + user_path = Path.home() / ".config" / "circuitforge" / "llm.yaml" + + def fake_exists(self): + return self == local_path # only the local path "exists" + + captured = {} + + def fake_core_init(self, config_path=None): + captured["config_path"] = config_path + self.config = {} + + with patch.object(Path, "exists", fake_exists), \ + patch.object(_CoreLLMRouter, "__init__", fake_core_init): + import importlib + import scripts.llm_router as mod + importlib.reload(mod) + mod.LLMRouter() + + assert captured.get("config_path") == local_path, ( + f"Expected super().__init__ to be called with local path {local_path}, " + f"got {captured.get('config_path')}" + ) + + +# --------------------------------------------------------------------------- +# Test 2: falls through to env-var auto-config when neither yaml exists +# --------------------------------------------------------------------------- + +def test_falls_through_to_env_when_no_yamls(): + """When no yaml files exist, super().__init__ is called with no args (env-var path).""" + import scripts.llm_router as shim_mod + from circuitforge_core.llm import LLMRouter as _CoreLLMRouter + + captured = {} + + def fake_exists(self): + return False # no yaml files exist anywhere + + def fake_core_init(self, config_path=None): + # Record whether a path was passed + captured["config_path"] = config_path + captured["called"] = True + self.config = {} + + with patch.object(Path, "exists", fake_exists), \ + patch.object(_CoreLLMRouter, "__init__", fake_core_init): + import importlib + import scripts.llm_router as mod + importlib.reload(mod) + mod.LLMRouter() + + assert captured.get("called"), "super().__init__ was never called" + # When called with no args, config_path defaults to None in our mock, + # meaning the shim correctly fell through to env-var auto-config + assert captured.get("config_path") is None, ( + f"Expected super().__init__ to be called with no explicit path (None), " + f"got {captured.get('config_path')}" + ) + + +# --------------------------------------------------------------------------- +# Test 3: module-level complete() singleton is only instantiated once +# --------------------------------------------------------------------------- + +def test_complete_singleton_is_reused(): + """complete() reuses the same LLMRouter instance across multiple calls.""" + import importlib + import scripts.llm_router as mod + importlib.reload(mod) + + # Reset singleton + mod._router = None + + instantiation_count = [0] + original_init = mod.LLMRouter.__init__ + + mock_router = MagicMock() + mock_router.complete.return_value = "OK" + + original_class = mod.LLMRouter + + class CountingRouter(original_class): + def __init__(self): + instantiation_count[0] += 1 + # Bypass real __init__ to avoid needing config files + self.config = {} + + def complete(self, prompt, system=None): + return "OK" + + # Patch the class in the module + mod.LLMRouter = CountingRouter + mod._router = None + + result1 = mod.complete("first call") + result2 = mod.complete("second call") + + assert result1 == "OK" + assert result2 == "OK" + assert instantiation_count[0] == 1, ( + f"Expected LLMRouter to be instantiated exactly once, " + f"got {instantiation_count[0]} instantiation(s)" + ) + + # Restore + mod.LLMRouter = original_class