POST /admin/api/runtime/classifier_degraded_warn_threshold with null is now pinned as a clean 422 (it was a TypeError/500 before the neutral_path guard). Covered: null, bool, string, 0.0, below-floor and above-max all 422 and leave cfg untouched; floor, 0.2 and max return 200 and update cfg. The float handler's 422 text had lost "or null for neutral" and "(null means neutral, not 0.0)" for every float knob, including the challenger dial, which does have a neutral. They are back, conditional on the knob having a neutral_path, and degraded_warn_threshold's 422s never mention null. Tests pin both directions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
675 lines
26 KiB
Python
675 lines
26 KiB
Python
"""Tests for the /admin/api runtime config toggle endpoints.
|
|
|
|
``admin.py`` exposes GET /admin/api/runtime (persisted + in-memory state for
|
|
each toggle knob) and POST /admin/api/runtime/{knob} (flip the in-memory cfg
|
|
value only, never config.yaml). These tests drive a real TestClient against the
|
|
seeded temp DB, mirroring ``test_admin_health.py``'s ``seeded_client`` fixture.
|
|
|
|
The key contract under test: a POST flips the RUNTIME value but must leave the
|
|
PERSISTED (config.yaml) value untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
import dispatcher
|
|
from admin import _INT_KNOBS
|
|
from config import (
|
|
DEGRADED_WARN_THRESHOLD_FLOOR,
|
|
DEGRADED_WARN_THRESHOLD_MAX,
|
|
STALENESS_SECONDS_MAX,
|
|
STALENESS_SECONDS_MIN,
|
|
load_config,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
|
|
CFG = load_config(str(ROOT / "config" / "config.yaml"))
|
|
|
|
|
|
def _make_db(tmp_path: Path) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(tmp_path / "test.db"))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.executescript(SCHEMA_SQL)
|
|
return conn
|
|
|
|
|
|
def _seed_models(conn: sqlite3.Connection) -> None:
|
|
for model_id, tier, context, cost, vision in (
|
|
("cheap", 2, 262128, 0.30, 1),
|
|
("dear", 2, 262128, 9.00, 0),
|
|
("tiny", 1, 131072, 0.10, 1),
|
|
):
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO models (
|
|
model_id, provider, base_model_id, tier, context_window,
|
|
effective_context_window, max_output_tokens,
|
|
cost_per_1m_prompt, cost_per_1m_completion,
|
|
supports_vision, supports_json_mode,
|
|
latency_class, reasoning_mode, context_variant,
|
|
access_level, availability, last_updated
|
|
) VALUES (?, 'neuralwatt', ?, ?, ?, 192500, 16384, ?, ?,
|
|
?, 1, 'standard', 'default', 'full', 'public', 'active',
|
|
'2026-08-22T00:00:00+00:00')
|
|
""",
|
|
(model_id, model_id, tier, context, cost, cost / 3, vision),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_client(tmp_path, monkeypatch):
|
|
"""A TestClient wired to a seeded temp DB, at /admin."""
|
|
conn = _make_db(tmp_path)
|
|
_seed_models(conn)
|
|
conn.close()
|
|
|
|
monkeypatch.setattr(dispatcher.cfg.database, "path", str(tmp_path / "test.db"))
|
|
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
|
|
monkeypatch.setattr(dispatcher.cfg.routing, "require_vision", False)
|
|
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
|
|
|
|
with TestClient(dispatcher.app) as client:
|
|
yield client
|
|
|
|
|
|
def test_runtime_GET_reports_every_knob(seeded_client):
|
|
"""GET /admin/api/runtime reports persisted + runtime for every knob."""
|
|
resp = seeded_client.get("/admin/api/runtime")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
for knob in (
|
|
"log_route_decisions",
|
|
"log_energy_observations",
|
|
"circuit_breaker_enabled",
|
|
"local_llm_enabled",
|
|
"session_cache_enabled",
|
|
"pinch_enabled",
|
|
"pinch_prefix_probe",
|
|
"pinch_relevance_enabled",
|
|
"incumbent_cache_pricing",
|
|
"incumbent_challenger_cache_rate",
|
|
"session_cache_staleness_seconds",
|
|
"default_flex_preference",
|
|
):
|
|
assert knob in body
|
|
assert set(body[knob]) == {"persisted", "runtime", "config_key", "category", "advanced"}
|
|
|
|
# The runtime value mirrors the live dispatcher.cfg for the boolean knobs;
|
|
# the persisted value mirrors config.yaml. circuit_breaker_enabled is a
|
|
# flat bool like every other knob (used to be nested {"enabled": ...}
|
|
# for no reason, which was why the admin UI never rendered a toggle for
|
|
# it — fixed alongside this test).
|
|
assert body["log_route_decisions"]["runtime"] == dispatcher.cfg.logging.log_route_decisions
|
|
assert body["log_route_decisions"]["persisted"] == CFG.logging.log_route_decisions
|
|
assert body["circuit_breaker_enabled"] == {
|
|
"persisted": CFG.circuit_breaker.enabled,
|
|
"runtime": dispatcher.cfg.circuit_breaker.enabled,
|
|
"config_key": "circuit_breaker.enabled",
|
|
"category": "safety_nets",
|
|
"advanced": False,
|
|
}
|
|
assert body["default_flex_preference"] == {
|
|
"persisted": CFG.routing.default_flex_preference.value,
|
|
"runtime": dispatcher.cfg.routing.default_flex_preference.value,
|
|
"config_key": "routing.default_flex_preference",
|
|
"category": "routing_quality_cost",
|
|
"advanced": False,
|
|
}
|
|
|
|
|
|
def test_toggle_log_route_decisions_flips_runtime_not_persisted(
|
|
seeded_client, monkeypatch
|
|
):
|
|
"""POST a boolean knob flips the runtime value; config.yaml is untouched."""
|
|
# Guard shared global state: record the original so it restores after this
|
|
# test, since the POST mutates dispatcher.cfg in place.
|
|
monkeypatch.setattr(
|
|
dispatcher.cfg.logging,
|
|
"log_route_decisions",
|
|
dispatcher.cfg.logging.log_route_decisions,
|
|
)
|
|
initial = seeded_client.get("/admin/api/runtime").json()
|
|
assert initial["log_route_decisions"]["runtime"] is True
|
|
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/log_route_decisions", json={"value": False}
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
after = seeded_client.get("/admin/api/runtime").json()
|
|
# Runtime flipped ...
|
|
assert after["log_route_decisions"]["runtime"] is False
|
|
# ... but persisted is unchanged and still matches config.yaml.
|
|
assert after["log_route_decisions"]["persisted"] == CFG.logging.log_route_decisions
|
|
assert after["log_route_decisions"]["persisted"] == initial["log_route_decisions"][
|
|
"persisted"
|
|
]
|
|
|
|
|
|
def test_toggle_pinch_prefix_probe_round_trips(seeded_client, monkeypatch):
|
|
"""pinch.prefix_probe reads, sets and reads back through the runtime knob.
|
|
|
|
The knob was deliberately left off ``_BOOL_KNOBS`` once, on the grounds
|
|
that a toggle whose only effect is to stop collecting evidence is odd UX.
|
|
The standing rule won: a knob reachable only by hand-editing
|
|
config.local.yaml is invisible to whoever operates the router. The UI
|
|
carries the warning instead.
|
|
"""
|
|
# Guard shared global state: the POST mutates dispatcher.cfg in place.
|
|
monkeypatch.setattr(
|
|
dispatcher.cfg.pinch, "prefix_probe", dispatcher.cfg.pinch.prefix_probe
|
|
)
|
|
initial = seeded_client.get("/admin/api/runtime").json()
|
|
assert initial["pinch_prefix_probe"]["runtime"] is True
|
|
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/pinch_prefix_probe", json={"value": False}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "pinch_prefix_probe": False}
|
|
|
|
after = seeded_client.get("/admin/api/runtime").json()
|
|
assert after["pinch_prefix_probe"]["runtime"] is False
|
|
assert dispatcher.cfg.pinch.prefix_probe is False
|
|
# config.yaml is untouched by a runtime flip.
|
|
assert after["pinch_prefix_probe"]["persisted"] == CFG.pinch.prefix_probe
|
|
|
|
|
|
def test_pinch_prefix_probe_rejects_a_non_boolean(seeded_client):
|
|
"""A string is refused rather than written straight onto cfg.
|
|
|
|
``_set_at`` bypasses every Pydantic validator (StrictModel sets
|
|
extra=forbid, not validate_assignment), so the endpoint's own type check
|
|
is the only thing standing between a bad body and a cfg field that reads
|
|
truthy forever.
|
|
"""
|
|
before = dispatcher.cfg.pinch.prefix_probe
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/pinch_prefix_probe", json={"value": "false"}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "expects a boolean" in resp.json()["detail"]
|
|
assert dispatcher.cfg.pinch.prefix_probe is before
|
|
|
|
|
|
@pytest.fixture
|
|
def incumbent_cfg_guard(monkeypatch):
|
|
"""Restore both incumbent knobs on cfg after a test mutates them."""
|
|
obj = dispatcher.cfg.objective
|
|
monkeypatch.setattr(obj, "incumbent_cache_pricing", obj.incumbent_cache_pricing)
|
|
monkeypatch.setattr(
|
|
obj,
|
|
"incumbent_challenger_cache_rate",
|
|
obj.incumbent_challenger_cache_rate,
|
|
)
|
|
return obj
|
|
|
|
|
|
def test_toggle_incumbent_cache_pricing_round_trips(
|
|
seeded_client, incumbent_cfg_guard
|
|
):
|
|
"""The Wave 2 gate flips in memory, which is the whole point of the dial.
|
|
|
|
Without a runtime knob the feature can only be tuned by hand-editing
|
|
config.local.yaml and bouncing the service, and the acceptance procedure
|
|
for it assumes repeated changes with a re-measurement between each. Nobody
|
|
walks that loop through a restart.
|
|
"""
|
|
assert incumbent_cfg_guard.incumbent_cache_pricing is False
|
|
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_cache_pricing", json={"value": True}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "incumbent_cache_pricing": True}
|
|
|
|
after = seeded_client.get("/admin/api/runtime").json()
|
|
assert after["incumbent_cache_pricing"]["runtime"] is True
|
|
assert dispatcher.cfg.objective.incumbent_cache_pricing is True
|
|
# config.yaml stays off — a runtime flip never persists.
|
|
assert after["incumbent_cache_pricing"]["persisted"] is False
|
|
|
|
|
|
def test_incumbent_cache_pricing_rejects_a_non_boolean(
|
|
seeded_client, incumbent_cfg_guard
|
|
):
|
|
"""``_set_at`` bypasses Pydantic, so the endpoint's type check is the guard."""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_cache_pricing", json={"value": "true"}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "expects a boolean" in resp.json()["detail"]
|
|
assert dispatcher.cfg.objective.incumbent_cache_pricing is False
|
|
|
|
|
|
def test_challenger_dial_sets_a_real_float(seeded_client, incumbent_cfg_guard):
|
|
"""0.0 is accepted and lands on cfg as a genuine float, not a string."""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": 0.0}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "incumbent_challenger_cache_rate": 0.0}
|
|
|
|
stored = dispatcher.cfg.objective.incumbent_challenger_cache_rate
|
|
assert isinstance(stored, float) and not isinstance(stored, bool)
|
|
assert stored == 0.0
|
|
assert (
|
|
seeded_client.get("/admin/api/runtime").json()[
|
|
"incumbent_challenger_cache_rate"
|
|
]["runtime"]
|
|
== 0.0
|
|
)
|
|
|
|
|
|
def test_blank_challenger_dial_is_neutral_not_zero(
|
|
seeded_client, incumbent_cfg_guard
|
|
):
|
|
"""null means NEUTRAL. It is the opposite end of the dial from 0.0.
|
|
|
|
This is the whole trap. ``null`` follows ``assumed_cache_rate``, so
|
|
challengers pay nothing for switching; ``0.0`` prices them as fully cold
|
|
prompts, the MAXIMUM penalty. An operator clearing the field to turn the
|
|
feature off must not get it turned all the way up, so a blank must not
|
|
become a zero anywhere between the input and cfg.
|
|
"""
|
|
assumed = dispatcher.cfg.objective.assumed_cache_rate
|
|
assert assumed != 0.0, "fixture would not distinguish neutral from full"
|
|
|
|
# Walk the dial to full penalty first, so the neutral write has to undo it.
|
|
seeded_client.post(
|
|
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": 0.0}
|
|
)
|
|
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == 0.0
|
|
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": None}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {
|
|
"ok": True,
|
|
"incumbent_challenger_cache_rate": assumed,
|
|
}
|
|
# Resolved, never stored as None: config.py's model validator makes the
|
|
# same substitution at load, and docs/routing.md promises downstream code
|
|
# never sees two representations of neutral.
|
|
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == assumed
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [-0.1, 1.5, 100])
|
|
def test_challenger_dial_refuses_values_outside_zero_to_one(
|
|
seeded_client, incumbent_cfg_guard, bad
|
|
):
|
|
"""A cache RATE lives in [0, 1]; 100 is the confidence_threshold mistake.
|
|
|
|
That incident wrote a raw ``80`` meaning 80% into the overlay for a field
|
|
the loader wanted as 0.0-1.0, and it was caught before the restart only by
|
|
luck. Here the range check is in the endpoint, because a runtime write
|
|
never reaches a Pydantic validator at all.
|
|
"""
|
|
before = dispatcher.cfg.objective.incumbent_challenger_cache_rate
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": bad}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "must be in [0.0, 1.0]" in resp.json()["detail"]
|
|
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == before
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["0.5", True, [0.5]])
|
|
def test_challenger_dial_refuses_a_non_number(
|
|
seeded_client, incumbent_cfg_guard, bad
|
|
):
|
|
"""A bool is the sharp one: True is an int in Python and would read 1.0."""
|
|
before = dispatcher.cfg.objective.incumbent_challenger_cache_rate
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": bad}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "expects a number" in resp.json()["detail"]
|
|
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == before
|
|
|
|
|
|
def test_challenger_dial_422_still_advertises_null_as_neutral(
|
|
seeded_client, incumbent_cfg_guard
|
|
):
|
|
"""The dial HAS a neutral, so its 422s must keep telling the operator so.
|
|
|
|
These hints were dropped once when a second float knob without a neutral
|
|
shared the handler; they are conditional on the knob having one.
|
|
"""
|
|
url = "/admin/api/runtime/incumbent_challenger_cache_rate"
|
|
not_a_number = seeded_client.post(url, json={"value": "x"})
|
|
assert not_a_number.status_code == 422
|
|
assert "or null for neutral" in not_a_number.json()["detail"]
|
|
|
|
out_of_range = seeded_client.post(url, json={"value": 1.5})
|
|
assert out_of_range.status_code == 422
|
|
assert "(null means neutral, not 0.0)" in out_of_range.json()["detail"]
|
|
|
|
|
|
@pytest.fixture
|
|
def degraded_threshold_cfg_guard(monkeypatch):
|
|
"""Restore classifier.degraded_warn_threshold after a test writes it."""
|
|
classifier = dispatcher.cfg.classifier
|
|
monkeypatch.setattr(
|
|
classifier, "degraded_warn_threshold", classifier.degraded_warn_threshold
|
|
)
|
|
return classifier
|
|
|
|
|
|
DEGRADED_THRESHOLD_URL = "/admin/api/runtime/classifier_degraded_warn_threshold"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
None,
|
|
True,
|
|
"x",
|
|
0.0,
|
|
DEGRADED_WARN_THRESHOLD_FLOOR / 2,
|
|
DEGRADED_WARN_THRESHOLD_MAX + 0.5,
|
|
],
|
|
ids=["null", "bool", "string", "zero", "below-floor", "above-max"],
|
|
)
|
|
def test_degraded_warn_threshold_refuses_bad_values_with_422(
|
|
seeded_client, degraded_threshold_cfg_guard, bad
|
|
):
|
|
"""A runtime write skips Pydantic, so the registry bounds are the only guard.
|
|
|
|
``null`` is the sharp one: this knob has no neutral, and the float handler
|
|
used to resolve null through ``_get_at(cfg, neutral_path)``, which raised
|
|
TypeError (HTTP 500) when that path is None.
|
|
"""
|
|
before = degraded_threshold_cfg_guard.degraded_warn_threshold
|
|
resp = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": bad})
|
|
assert resp.status_code == 422
|
|
assert degraded_threshold_cfg_guard.degraded_warn_threshold == before
|
|
|
|
|
|
def test_degraded_warn_threshold_422_never_advertises_null(
|
|
seeded_client, degraded_threshold_cfg_guard
|
|
):
|
|
"""No neutral means no ``null`` hint: it would send the operator into a 422."""
|
|
for bad in (None, "x", 1.5):
|
|
detail = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": bad}).json()[
|
|
"detail"
|
|
]
|
|
assert "neutral" not in detail
|
|
assert "null" not in detail
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"good",
|
|
[DEGRADED_WARN_THRESHOLD_FLOOR, 0.2, DEGRADED_WARN_THRESHOLD_MAX],
|
|
ids=["floor", "typical", "max"],
|
|
)
|
|
def test_degraded_warn_threshold_accepts_in_range_and_updates_cfg(
|
|
seeded_client, degraded_threshold_cfg_guard, good
|
|
):
|
|
resp = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": good})
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "classifier_degraded_warn_threshold": good}
|
|
|
|
stored = degraded_threshold_cfg_guard.degraded_warn_threshold
|
|
assert isinstance(stored, float) and not isinstance(stored, bool)
|
|
assert stored == good
|
|
assert (
|
|
seeded_client.get("/admin/api/runtime").json()[
|
|
"classifier_degraded_warn_threshold"
|
|
]["runtime"]
|
|
== good
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def staleness_cfg_guard(monkeypatch):
|
|
"""Restore the session-cache window on cfg after a test moves it."""
|
|
sc = dispatcher.cfg.session_cache
|
|
monkeypatch.setattr(sc, "staleness_seconds", sc.staleness_seconds)
|
|
return sc
|
|
|
|
|
|
def test_staleness_window_sets_a_real_int(seeded_client, staleness_cfg_guard):
|
|
"""The runtime half is the valuable half: this knob is meant to be swept.
|
|
|
|
The window decides how long ONE classification keeps steering routing, and
|
|
the only settings the portal offered before this were "20 minutes" and "no
|
|
cache at all" (``session_cache_enabled``). Narrowing it and re-measuring
|
|
the replay share is a loop nobody walks through a tracked-file edit and a
|
|
``systemctl --user restart``; ``dispatcher`` reads
|
|
``cfg.session_cache.staleness_seconds`` per request, so a POST is live on
|
|
the next one.
|
|
"""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": 5}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "session_cache_staleness_seconds": 5}
|
|
|
|
stored = dispatcher.cfg.session_cache.staleness_seconds
|
|
# A real int, not a float and not a bool: the field is declared ``int``,
|
|
# and a float here would be a value load_config could never produce, so
|
|
# the runtime and persisted halves of one control would disagree about
|
|
# their own type.
|
|
assert isinstance(stored, int) and not isinstance(stored, bool)
|
|
assert stored == 5
|
|
|
|
after = seeded_client.get("/admin/api/runtime").json()
|
|
assert after["session_cache_staleness_seconds"]["runtime"] == 5
|
|
# config.yaml is untouched by a runtime write.
|
|
assert (
|
|
after["session_cache_staleness_seconds"]["persisted"]
|
|
== CFG.session_cache.staleness_seconds
|
|
)
|
|
|
|
|
|
def test_blank_staleness_window_is_refused_not_read_as_zero(
|
|
seeded_client, staleness_cfg_guard
|
|
):
|
|
"""Clearing the field is not a setting, and the refusal says which one is.
|
|
|
|
``Number('')`` is 0 in JavaScript, and 0 here would look like "stop
|
|
reusing labels" while being nothing of the sort: ``session_cache.put``
|
|
still writes, and the classifier-failure cascade's ``stale_read`` ignores
|
|
staleness entirely, so the entry is still replayed whenever the classifier
|
|
is down. The switch that actually stops reuse is ``session_cache_enabled``,
|
|
so the 422 names it rather than leaving the operator to guess.
|
|
"""
|
|
before = dispatcher.cfg.session_cache.staleness_seconds
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": None}
|
|
)
|
|
assert resp.status_code == 422
|
|
detail = resp.json()["detail"]
|
|
assert "no blank setting" in detail
|
|
assert "session_cache_enabled" in detail
|
|
assert dispatcher.cfg.session_cache.staleness_seconds == before
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [0, -1, 4, 7201, 10000])
|
|
def test_staleness_window_refuses_values_outside_its_bounds(
|
|
seeded_client, staleness_cfg_guard, bad
|
|
):
|
|
"""0 is not "off" and there is no unbounded window.
|
|
|
|
Both ends are decisions. The floor is 5 because 0 reads as off and is not
|
|
(see the blank test). The ceiling is 7200 because an unbounded window is a
|
|
cache that never expires — 6x the shipped default and ~8.5x the longest
|
|
single-classification run measured on live traffic (107 consecutive turns
|
|
across 840 seconds), so it is above every value there is a reason to try.
|
|
"""
|
|
before = dispatcher.cfg.session_cache.staleness_seconds
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": bad}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "must be in [5, 7200]" in resp.json()["detail"]
|
|
assert dispatcher.cfg.session_cache.staleness_seconds == before
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["20", True, False, 20.5, 20.0, [20]])
|
|
def test_staleness_window_refuses_a_non_integer(
|
|
seeded_client, staleness_cfg_guard, bad
|
|
):
|
|
"""``True`` is the sharp one: it is an ``int`` in Python and reads as 1.
|
|
|
|
A checkbox body landing on this field would silently set a 1-second
|
|
window. Floats are refused rather than truncated for the same reason a
|
|
string is: 20.5 quietly becoming 20 is a value the operator never chose,
|
|
and 20.0 pays the same price for one unambiguous rule.
|
|
"""
|
|
before = dispatcher.cfg.session_cache.staleness_seconds
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": bad}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "expects a whole number of seconds" in resp.json()["detail"]
|
|
assert dispatcher.cfg.session_cache.staleness_seconds == before
|
|
|
|
|
|
def test_staleness_runtime_bounds_match_the_config_validator():
|
|
"""One range, two enforcement points, imported rather than written twice.
|
|
|
|
A runtime write bypasses every Pydantic validator (``StrictModel`` sets
|
|
``extra="forbid"``, not ``validate_assignment``), so the table in admin.py
|
|
is the ONLY guard on that path — and a table that drifted from the config
|
|
validator would let an operator set at runtime a value the service refuses
|
|
to boot with.
|
|
"""
|
|
path, low, high = _INT_KNOBS["session_cache_staleness_seconds"]
|
|
assert path == ("session_cache", "staleness_seconds")
|
|
assert (low, high) == (STALENESS_SECONDS_MIN, STALENESS_SECONDS_MAX)
|
|
|
|
|
|
def test_post_invalid_flex_value_returns_422(seeded_client):
|
|
"""A flex preference outside the 4 allowed values is rejected with 422."""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/default_flex_preference", json={"value": "banana"}
|
|
)
|
|
assert resp.status_code == 422
|
|
# The in-memory value must not have changed.
|
|
assert (
|
|
seeded_client.get("/admin/api/runtime").json()["default_flex_preference"][
|
|
"runtime"
|
|
]
|
|
== "auto"
|
|
)
|
|
|
|
|
|
def test_post_valid_flex_value_updates_runtime(seeded_client, monkeypatch):
|
|
"""A valid flex preference is accepted and reflected in runtime state."""
|
|
monkeypatch.setattr(
|
|
dispatcher.cfg.routing,
|
|
"default_flex_preference",
|
|
dispatcher.cfg.routing.default_flex_preference,
|
|
)
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/default_flex_preference", json={"value": "force-flex"}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert seeded_client.get("/admin/api/runtime").json()["default_flex_preference"][
|
|
"runtime"
|
|
] == "force-flex"
|
|
|
|
|
|
def test_post_unknown_knob_returns_400(seeded_client):
|
|
"""POSTing an unknown knob name is rejected with 400."""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/not_a_real_knob", json={"value": True}
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_post_non_boolean_for_bool_knob_returns_422(seeded_client):
|
|
"""POSTing a non-boolean value for a boolean knob is rejected with 422."""
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/session_cache_enabled", json={"value": "yes"}
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ── active_profile: switching what `auto` resolves to, without a restart ────
|
|
#
|
|
# The restart this removes was never required by the router: _resolve_profile
|
|
# reads cfg.routing.default_profile per request. It was required because the
|
|
# portal wrote the value to config.local.yaml and nothing touched the running
|
|
# cfg. So active_profile is the SAME config path as the persisted
|
|
# routing.default_profile, split by where it is written -- runtime here,
|
|
# persisted through /api/config.
|
|
|
|
|
|
def test_active_profile_is_reported_with_persisted_and_runtime(seeded_client):
|
|
resp = seeded_client.get("/admin/api/runtime")
|
|
assert resp.status_code == 200
|
|
assert set(resp.json()["active_profile"]) == {
|
|
"persisted",
|
|
"runtime",
|
|
"config_key",
|
|
"category",
|
|
"advanced",
|
|
}
|
|
|
|
|
|
def test_active_profile_switch_changes_what_auto_resolves_to(
|
|
seeded_client, monkeypatch
|
|
):
|
|
"""The whole point: no restart between the switch and the effect."""
|
|
# Guard shared global state: the POST mutates dispatcher.cfg in place,
|
|
# and cfg.routing.default_profile is read by every later test's routing.
|
|
monkeypatch.setattr(
|
|
dispatcher.cfg.routing,
|
|
"default_profile",
|
|
dispatcher.cfg.routing.default_profile,
|
|
)
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/active_profile", json={"value": "batch"}
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "active_profile": "batch"}
|
|
# The value the request path actually reads on the next `auto` call.
|
|
assert dispatcher.cfg.routing.default_profile == "batch"
|
|
|
|
|
|
def test_active_profile_refuses_an_unknown_name(seeded_client, monkeypatch):
|
|
"""StrictModel sets extra=forbid but NOT validate_assignment, so _set_at
|
|
writes straight past RouterConfig's validators, including
|
|
default_profile_names_a_known_profile. Unchecked, an unknown name would be
|
|
accepted here and then 422 every `auto` request -- a failure landing far
|
|
from its cause."""
|
|
before = dispatcher.cfg.routing.default_profile
|
|
resp = seeded_client.post(
|
|
"/admin/api/runtime/active_profile", json={"value": "no-such-profile"}
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "must name a known profile" in resp.json()["detail"]
|
|
assert dispatcher.cfg.routing.default_profile == before
|
|
|
|
|
|
def test_active_profile_endpoint_reports_active_boots_to_and_choices(
|
|
seeded_client, monkeypatch
|
|
):
|
|
# Guard shared global state: the POST mutates dispatcher.cfg in place,
|
|
# and cfg.routing.default_profile is read by every later test's routing.
|
|
monkeypatch.setattr(
|
|
dispatcher.cfg.routing,
|
|
"default_profile",
|
|
dispatcher.cfg.routing.default_profile,
|
|
)
|
|
seeded_client.post("/admin/api/runtime/active_profile", json={"value": "batch"})
|
|
body = seeded_client.get("/admin/api/active-profile").json()
|
|
assert body["active"] == "batch"
|
|
assert "boots_to" in body
|
|
assert "batch" in body["available"]
|
|
# boots_to is read from disk, active from memory: after a runtime-only
|
|
# switch they diverge, and that divergence is what the switcher must show
|
|
# rather than hide -- a restart silently reverts routing to boots_to.
|
|
assert body["boots_to"] == CFG.routing.default_profile
|