Files
6krrt/tests/test_admin_classifier_config.py
adlee-was-taken ab527c3d49 fix(admin): label the Classifier card and show what is running vs saved
The card's inputs had placeholders but no labels, its badge reported the
saved mode as if it were live, and a typed 0 was rewritten to the default.

- Every input in every mode carries a visible label; fields align to the top.
- GET /admin/api/classifier-config now returns running_mode and
  restart_pending alongside the saved mode. restart_pending compares the whole
  classifier block, and is null when the saved config no longer validates.
- The header badge names the running mode; an amber "restart pending" badge
  shows until the service restarts, since a save only persists the overlay.
- local_decision collect omits blank fields so repo defaults keep floating,
  and no longer turns a deliberate 0 into the default.
- local_encoder threshold placeholder shortened so it is not clipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
2026-10-03 00:11:32 -04:00

393 lines
14 KiB
Python

"""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