"""GET/POST /admin/api/classifier-config. The lesson this reuses rather than relearns (see the profiles zero-admit fix): the admin surface must report what dispatch actually does, using the same functions dispatch uses. The GET side's "resolved_primary" for cloud_primary_auto calls routing.cheapest_classifier_candidate directly -- these tests assert the reported value is provably that function's answer against a seeded catalog, not a static echo of the config. POST validates the same way config load does (RouterConfig(**merged) inside _persist_many_to), so these mirror test_admin_profiles.py's validator tests: one case proving each invalid combination is rejected, one proving the matching case is accepted. """ from __future__ import annotations import sqlite3 from pathlib import Path import pytest import yaml from fastapi import FastAPI from starlette.testclient import TestClient from admin import build_router from config import load_config ROOT = Path(__file__).resolve().parent.parent SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text() ADMIN_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS admin_model_overrides ( model_id TEXT NOT NULL, provider TEXT NOT NULL, availability TEXT NOT NULL, reason TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (model_id, provider) ); """ def _model_row(model_id: str, *, tier: int = 1, cost_completion: float = 1.0) -> dict: return { "model_id": model_id, "provider": "neuralwatt", "base_model_id": model_id, "display_name": model_id, "cost_per_1m_prompt": cost_completion * 0.5, "cost_per_1m_completion": cost_completion, "context_window": 131072, "effective_context_window": 65536, "max_output_tokens": 8192, "tier": tier, "supports_tools": 1, "supports_json_mode": 1, "supports_vision": 1, "supports_reasoning": 1, "reasoning_default_enabled": 1, "latency_class": "standard", "reasoning_mode": "default", "context_variant": "full", "access_level": "public", "deprecated": 0, "availability": "active", "last_updated": "2026-09-01T00:00:00+00:00", } def _seed_models(conn: sqlite3.Connection, rows: list[dict]) -> None: cols = list(rows[0].keys()) placeholders = ",".join(["?"] * len(cols)) for r in rows: conn.execute( f"INSERT INTO models ({','.join(cols)}) VALUES ({placeholders})", [r[c] for c in cols], ) conn.commit() def _client(tmp_path, model_rows: list[dict] | None = None, overlay_yaml: str | None = None): (tmp_path / "config").mkdir(parents=True, exist_ok=True) config_yaml = tmp_path / "config" / "config.yaml" local_yaml = tmp_path / "config" / "config.local.yaml" config_yaml.write_text((ROOT / "config" / "config.yaml").read_text()) if overlay_yaml is not None: local_yaml.write_text(overlay_yaml) cfg = load_config(str(config_yaml)) conn = sqlite3.connect(str(tmp_path / "admin.db")) conn.row_factory = sqlite3.Row conn.executescript(SCHEMA_SQL) conn.executescript(ADMIN_TABLE_SQL) if model_rows: _seed_models(conn, model_rows) conn.close() def _db_factory() -> sqlite3.Connection: c = sqlite3.connect(str(tmp_path / "admin.db")) c.row_factory = sqlite3.Row return c router = build_router(cfg, _db_factory, base_dir=str(tmp_path)) app = FastAPI() app.include_router(router, prefix="/admin") return TestClient(app), config_yaml, local_yaml # --- GET --------------------------------------------------------------- def test_get_reports_the_default_mode_sourced_from_base(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) body = client.get("/admin/api/classifier-config").json() assert body["mode"] == "local_llm" assert body["mode_source"] == "base" assert body["resolved_primary"] is None def test_get_reports_overlay_provenance_after_a_write(tmp_path): client, _config_yaml, _local_yaml = _client( tmp_path, model_rows=[_model_row("m1")] ) client.post( "/admin/api/classifier-config", json={"mode": "cloud_llm", "cloud_primary_auto": True}, ) body = client.get("/admin/api/classifier-config").json() assert body["mode"] == "cloud_llm" assert body["mode_source"] == "overlay" def test_get_resolves_the_live_cheapest_candidate_for_auto_mode(tmp_path): """The core claim: resolved_primary is NOT a static echo, it is provably routing.cheapest_classifier_candidate's own answer.""" client, _config_yaml, local_yaml = _client( tmp_path, model_rows=[ _model_row("expensive", cost_completion=10.0), _model_row("cheap", cost_completion=0.2), ], ) local_yaml.write_text( "classifier:\n mode: cloud_llm\n cloud_primary_auto: true\n" ) body = client.get("/admin/api/classifier-config").json() assert body["mode"] == "cloud_llm" assert body["cloud_primary_auto"] is True assert body["resolved_primary"]["model_id"] == "cheap" def test_get_reports_no_resolution_when_pinned_not_auto(tmp_path): client, _config_yaml, local_yaml = _client( tmp_path, model_rows=[_model_row("m1")] ) local_yaml.write_text( "classifier:\n" " mode: cloud_llm\n" " cloud_primary:\n" " base_url: https://api.neuralwatt.com/v1\n" " model: deepseek-v4-flash\n" ) body = client.get("/admin/api/classifier-config").json() assert body["resolved_primary"] is None assert body["cloud_primary"]["model"] == "deepseek-v4-flash" def test_get_classifier_config_includes_decision_block(tmp_path): """GET /admin/api/classifier-config includes decision block when mode is local_decision.""" client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "local_decision", "decision": {"coverage_min": 0.42}}, ) assert resp.status_code == 200 resp = client.get("/admin/api/classifier-config") body = resp.json() assert body["mode"] == "local_decision" assert body.get("decision") is not None assert body["decision"]["coverage_min"] == 0.42 # --- GET: saved vs running --------------------------------------------- # # POST only persists and cfg binds at import, so a saved classifier change is # inert until a restart. GET must say so, or the card reads as live when it is # not (which cost a restart cycle in practice). The router is built from the # base config only (tests ignore the overlay), so anything written to the # overlay is "saved but not running" by construction. def test_get_reports_nothing_pending_when_saved_matches_running(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) body = client.get("/admin/api/classifier-config").json() assert body["running_mode"] == "local_llm" assert body["mode"] == "local_llm" assert body["restart_pending"] is False def test_get_flags_a_saved_mode_change_as_pending_and_keeps_running_mode(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "local_decision", "decision": {}}, ) assert resp.status_code == 200 body = client.get("/admin/api/classifier-config").json() assert body["mode"] == "local_decision" # what is saved assert body["running_mode"] == "local_llm" # what is actually answering assert body["restart_pending"] is True def test_get_flags_a_companion_only_change_as_pending(tmp_path): """Same mode, different block: a saved cloud_fallback is just as inert until the restart as a saved mode, so comparing modes alone is not enough.""" client, _config_yaml, _local_yaml = _client( tmp_path, overlay_yaml=( "classifier:\n" " cloud_fallback:\n" " base_url: http://127.0.0.1:9/v1\n" " model: some-model\n" ), ) body = client.get("/admin/api/classifier-config").json() assert body["mode"] == body["running_mode"] == "local_llm" assert body["restart_pending"] is True def test_get_reports_pending_as_unknown_when_the_saved_config_is_invalid(tmp_path): """An overlay that does not validate cannot be compared; null, not a guess.""" client, _config_yaml, _local_yaml = _client( tmp_path, overlay_yaml="classifier:\n mode: local_encoder\n encoder:\n device: tpu\n", ) resp = client.get("/admin/api/classifier-config") assert resp.status_code == 200 assert resp.json()["restart_pending"] is None # --- POST: validated the same way config load is ------------------------ def test_post_cloud_llm_without_either_primary_is_rejected(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post("/admin/api/classifier-config", json={"mode": "cloud_llm"}) assert resp.status_code == 422 assert "cloud_primary" in resp.json()["detail"] def test_post_cloud_llm_with_blank_pinned_fields_is_rejected(tmp_path): """A present-but-empty cloud_primary is a truthy dict -- a bare `if not body.cloud_primary` would miss this. Found live: the admin form sent {"base_url": "", "model": ""} for empty inputs and it silently saved.""" client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={ "mode": "cloud_llm", "cloud_primary": {"base_url": "", "model": ""}, }, ) assert resp.status_code == 422 assert not local_yaml.exists(), "a rejected save must not touch the overlay" def test_post_cloud_llm_with_only_model_blank_is_rejected(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={ "mode": "cloud_llm", "cloud_primary": {"base_url": "https://api.neuralwatt.com/v1", "model": ""}, }, ) assert resp.status_code == 422 def test_post_cloud_llm_pinned_writes_both_keys_atomically(tmp_path): client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={ "mode": "cloud_llm", "cloud_primary": { "base_url": "https://api.neuralwatt.com/v1", "model": "deepseek-v4-flash", }, }, ) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["mode"] == "cloud_llm" assert written["classifier"]["cloud_primary"]["model"] == "deepseek-v4-flash" assert written["classifier"]["cloud_primary_auto"] is False def test_post_cloud_llm_auto_is_accepted(tmp_path): client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "cloud_llm", "cloud_primary_auto": True}, ) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["cloud_primary_auto"] is True assert "cloud_primary" not in written["classifier"] def test_post_switching_to_auto_clears_a_leftover_pinned_primary(tmp_path): """Without this, RouterConfig's own validator would reject the save -- both cloud_primary and cloud_primary_auto set is exactly what it rejects.""" client, _config_yaml, local_yaml = _client(tmp_path) client.post( "/admin/api/classifier-config", json={ "mode": "cloud_llm", "cloud_primary": { "base_url": "https://api.neuralwatt.com/v1", "model": "deepseek-v4-flash", }, }, ) resp = client.post( "/admin/api/classifier-config", json={"mode": "cloud_llm", "cloud_primary_auto": True}, ) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["cloud_primary_auto"] is True assert "cloud_primary" not in written["classifier"] def test_post_local_encoder_without_encoder_block_still_loads_with_defaults(tmp_path): """The body's encoder field is optional; LocalEncoderConfig fills in every field, so an empty block is a valid save.""" client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post("/admin/api/classifier-config", json={"mode": "local_encoder"}) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["mode"] == "local_encoder" assert written["classifier"]["encoder"] == {} def test_post_local_encoder_with_bad_device_is_rejected(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "local_encoder", "encoder": {"device": "tpu"}}, ) assert resp.status_code == 422 def test_post_local_llm_is_always_accepted(tmp_path): client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post("/admin/api/classifier-config", json={"mode": "local_llm"}) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["mode"] == "local_llm" def test_post_response_names_a_restart_is_required(tmp_path): client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post("/admin/api/classifier-config", json={"mode": "local_llm"}) assert "restart" in resp.json()["message"].lower() def test_post_local_decision_mode_returns_200(tmp_path): """local_decision mode with an empty decision block is accepted.""" client, _config_yaml, local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "local_decision", "decision": {}}, ) assert resp.status_code == 200 written = yaml.safe_load(local_yaml.read_text()) assert written["classifier"]["mode"] == "local_decision" assert written["classifier"]["decision"] == {} def test_post_invalid_mode_returns_422(tmp_path): """A mode that is not in the Literal is rejected.""" client, _config_yaml, _local_yaml = _client(tmp_path) resp = client.post( "/admin/api/classifier-config", json={"mode": "invalid_mode"}, ) assert resp.status_code == 422