"""The classifier and the local verifier are separate endpoints. They used to be one: the verifier derived its URL by stripping ``/v1`` off ``classifier.base_url``. That silently coupled two unrelated decisions, and the coupling only becomes visible when the classifier is moved off-host — pointing classification at a cloud provider would have sent every local verification to ``/api/chat``, which does not exist. The verifier speaks Ollama's NATIVE API (``/api/chat`` with ``think=False``) because that is the only way to disable the reasoning trace, so it cannot follow the classifier anywhere the classifier can go. """ from __future__ import annotations import copy import pytest import yaml from config import RouterConfig @pytest.fixture def raw() -> dict: with open("config.yaml") as fh: return yaml.safe_load(fh) def test_moving_the_classifier_to_a_cloud_provider_leaves_the_verifier_local(raw): cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1" cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY" cfg["classifier"]["model"] = "deepseek-v4-flash" loaded = RouterConfig(**cfg) assert loaded.classifier.base_url == "https://api.neuralwatt.com/v1" # The whole point: this did NOT follow the line above. assert "localhost" in loaded.verification.base_url def test_the_verifier_defaults_to_a_local_ollama(raw): cfg = copy.deepcopy(raw) cfg["verification"].pop("base_url", None) assert RouterConfig(**cfg).verification.base_url == "http://localhost:11434" def test_an_absent_api_key_env_means_unauthenticated(raw): # The local Ollama case. It ignores the key entirely, but the SDK requires # one to be set, so the dispatcher substitutes a placeholder. Asserted on # an explicit config rather than on whatever config.yaml currently says, # which is a deployment choice and not a property of the code. cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "http://localhost:11434/v1" cfg["classifier"].pop("api_key_env", None) assert RouterConfig(**cfg).classifier.api_key_env is None cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY" assert RouterConfig(**cfg).classifier.api_key_env == "NEURALWATT_API_KEY" def test_verification_model_may_be_unset_while_both_run_on_one_host(raw): cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "http://localhost:11434/v1" cfg["verification"]["model"] = None loaded = RouterConfig(**cfg) # Resolved by the dispatcher as `verification.model or classifier.model`, # which is right only because the hosts match. assert loaded.verification.model is None def test_an_unset_verifier_model_is_refused_once_the_hosts_differ(raw): # The failure this prevents is SILENT, which is why it is a load-time # error rather than a documented caveat. Observed directly: with the # classifier on NeuralWatt and this left null, the verifier POSTed # `deepseek-v4-flash` to localhost:11434, 404d, caught it, logged "local # verification unavailable" and recorded no sample. Verification looked # enabled while producing nothing. cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1" cfg["classifier"]["model"] = "deepseek-v4-flash" cfg["verification"]["model"] = None cfg["verification"]["local_llm_enabled"] = True with pytest.raises(ValueError, match="verification.model must be set"): RouterConfig(**cfg) def test_a_split_host_setup_loads_once_the_verifier_model_is_stated(raw): cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1" cfg["classifier"]["api_key_env"] = "NEURALWATT_API_KEY" cfg["classifier"]["model"] = "deepseek-v4-flash" cfg["verification"]["model"] = "qwen3.5:latest" loaded = RouterConfig(**cfg) assert loaded.classifier.model == "deepseek-v4-flash" assert loaded.verification.model == "qwen3.5:latest" def test_disabling_the_local_check_lifts_the_requirement(raw): # A host with no local inference at all: nothing to name, nothing to guard. cfg = copy.deepcopy(raw) cfg["classifier"]["base_url"] = "https://api.neuralwatt.com/v1" cfg["verification"]["model"] = None cfg["verification"]["local_llm_enabled"] = False loaded = RouterConfig(**cfg) assert loaded.verification.local_llm_enabled is False def test_a_host_with_no_local_inference_can_turn_the_local_check_off(raw): # An RPi has no usable local model. Structural verification is pure Python # and keeps running; only the LLM check goes away. cfg = copy.deepcopy(raw) cfg["verification"]["local_llm_enabled"] = False loaded = RouterConfig(**cfg) assert loaded.verification.local_llm_enabled is False # --- an unknown key is an error, not a no-op ------------------------------ def test_a_key_in_the_wrong_section_is_rejected(raw): # Exactly the mistake that shipped: max_input_chars belongs to the # classifier and was written into verification, where pydantic's default # extra="ignore" accepted it, dropped it, and left the code default in # force. It carried the same value, so nothing looked wrong -- but editing # it would have done nothing at all. cfg = copy.deepcopy(raw) cfg["verification"]["max_input_chars"] = 4000 with pytest.raises(ValueError, match="max_input_chars"): RouterConfig(**cfg) def test_a_misspelled_key_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["routing"]["min_tool_proficency"] = 0.5 # sic with pytest.raises(ValueError, match="min_tool_proficency"): RouterConfig(**cfg) def test_the_shipped_config_has_no_unknown_keys(raw): # Guards the whole file, not just the sections a test happens to name. RouterConfig(**raw) def test_the_tool_filter_can_be_turned_off_from_config(raw): # It is a knob to experiment with, so null must be a legal value rather # than something requiring a code change. cfg = copy.deepcopy(raw) cfg["routing"]["min_tool_proficiency"] = None assert RouterConfig(**cfg).routing.min_tool_proficiency is None def test_disabling_the_filter_lifts_the_category_name_check(raw): # With no filter there is nothing to join, so an unused category name # must not block startup. cfg = copy.deepcopy(raw) cfg["routing"]["min_tool_proficiency"] = None cfg["routing"]["tool_use_category"] = "not_a_real_category" assert RouterConfig(**cfg).routing.min_tool_proficiency is None # --- the CLI sanity check ------------------------------------------------- def test_the_config_summary_names_fields_that_exist(raw): """`python config.py` is the documented setup step, and it crashed. It printed cfg.weights, which had been replaced by cfg.objective, so the one command whose job is to prove the config is fine reported "Config loaded OK" and then died with AttributeError. A function plus this test means the names cannot rot silently again. """ from config import summary_lines lines = summary_lines(RouterConfig(**raw)) assert lines[0] == "Config loaded OK" body = "\n".join(lines[1:]) assert "quality_tolerance=" in body assert "classifier:" in body assert "dispatch providers:" in body def test_a_removed_key_is_rejected_rather_than_ignored(raw): """log_path named a file nothing ever wrote; leaving it valid would lie.""" import copy import pytest cfg = copy.deepcopy(raw) cfg["logging"]["log_path"] = "router.log" with pytest.raises(Exception, match="log_path|Extra inputs"): RouterConfig(**cfg) # --- capability gates and the local vision fallback ------------------------ def test_the_shipped_config_loads_with_the_new_keys(raw): loaded = RouterConfig(**raw) assert loaded.routing.require_vision is True assert loaded.routing.require_json_mode is True assert loaded.local_vision.model == "qwen3-vl:4b" assert loaded.local_vision.enabled is True def test_a_typo_in_require_vision_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["routing"]["require_visoin"] = True # sic with pytest.raises(ValueError, match="require_visoin"): RouterConfig(**cfg) def test_local_vision_can_be_disabled_from_config(raw): cfg = copy.deepcopy(raw) cfg["local_vision"]["enabled"] = False assert RouterConfig(**cfg).local_vision.enabled is False def test_local_vision_defaults_when_absent(raw): cfg = copy.deepcopy(raw) cfg.pop("local_vision") assert RouterConfig(**cfg).local_vision.enabled is True def test_nonpositive_local_timeout_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["local_vision"]["timeout_seconds"] = 0 with pytest.raises(ValueError, match="timeout_seconds"): RouterConfig(**cfg) def test_the_shipped_config_loads_the_pinch_section(raw): loaded = RouterConfig(**raw) assert loaded.pinch.enabled is False assert loaded.pinch.budget_tokens > 0 assert loaded.pinch.keep_last_turns > 0 def test_pinch_defaults_when_absent(raw): cfg = copy.deepcopy(raw) cfg.pop("pinch") loaded = RouterConfig(**cfg) assert loaded.pinch.enabled is False assert loaded.pinch.budget_tokens == 50000 assert loaded.pinch.keep_last_turns == 4 def test_nonpositive_pinch_budget_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["pinch"]["budget_tokens"] = 0 with pytest.raises(ValueError, match="budget_tokens"): RouterConfig(**cfg) def test_nonpositive_pinch_keep_last_turns_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["pinch"]["keep_last_turns"] = 0 with pytest.raises(ValueError, match="keep_last_turns"): RouterConfig(**cfg) def test_classifier_context_framing_loads(raw): # Defaults on (see config.yaml); the legacy layout is opt-out. assert RouterConfig(**raw).classifier.context_framing is True cfg = copy.deepcopy(raw) cfg["classifier"]["context_framing"] = False assert RouterConfig(**cfg).classifier.context_framing is False def test_nonminimum_pinch_max_summarize_chars_is_rejected(raw): cfg = copy.deepcopy(raw) cfg["pinch"]["max_summarize_chars"] = 100 with pytest.raises(ValueError, match="max_summarize_chars.*>=.*3000"): RouterConfig(**cfg) def test_pinch_max_summarize_chars_at_valid_min_loads(raw): cfg = copy.deepcopy(raw) cfg["pinch"]["max_summarize_chars"] = 3000 loaded = RouterConfig(**cfg) assert loaded.pinch.max_summarize_chars == 3000 def test_pinch_max_summarize_chars_accepts_default(raw): loaded = RouterConfig(**raw) assert loaded.pinch.max_summarize_chars == 4000