Files
6krrt/tests/test_config_endpoints.py
adlee-was-taken 5f7716e121 fix: resolve context-pruning-and-framing review findings
Address the 11 confirmed bugs and 4 cleanups from
context-pruning-and-framing-review.md, plus the image-accounting
regression it found on a second pass.

classifier framing:
- _previous_context walks back to the nearest assistant turn only,
  skipping system/user/tool so raw tool output and the system prompt
  never contaminate the framing signal; reuses context_prune.extract_text
  instead of a duplicated block parser.
- classify() drops its redundant if/not-context branch.
- framing instruction moved out of the static system prompt and into
  _classifier_user_content so it travels only with content that justifies
  it (respects context_framing opt-out).

context pruning:
- extract_text counts image_url bytes so image-bearing tool results are
  sized (and pruned) correctly.
- -- new, follow-up review -- image_url blocks now count toward the size
  trigger but not toward the replacement text, and are stubbed to a short
  marker when the message is trimmed, so an image-heavy tool result
  genuinely shrinks rather than staying full-size while tokens_saved lied
  and base64 leaked into the text block.
- tokens_saved is now the honest whole-list before/after reduction
  (max(orig - final, 0)), never a per-message estimate that could drift.
- max_summarize_chars validated >= 3000 at config load; trim branch guards
  against negative math and message growth.
- recency guard protects the newest tool result even with no user turn.
- non-text blocks survive trimming (type preserved) rather than being
  flattened away.
- dropped/stat terminology corrected to summarized; _turn_of() removed;
  module-level pinch defaults removed in favor of PinchConfig.

Dispatch and config:
- prune_context runs once before the measured-context routing decision
  (reused at dispatch, no double-prune, no in-place mutation); no-op when
  pinch is disabled.
- dead context=prev_context arg removed from the measured reroute.
- TaskRequest.context dual use documented.

15 new regression tests pin the fixes; 545 tests pass.
2026-08-23 22:23:08 -04:00

291 lines
10 KiB
Python

"""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 ``<provider>/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