Change the unit of session_cache.staleness from minutes to seconds so it can express finer-grained (sub-minute) staleness windows. This is a straight rename, not an additive/compat knob — no deprecated alias, per the project's convention of updating every consumer in the same change. New bounds: floor 5 seconds (was 1 minute), ceiling 7200 seconds (was 120 minutes). Default: 1200 seconds (was 20 minutes). The validator's reasoning is unit-independent and carries over: the floor is deliberately > 0 because 0 would make session_cache.get() miss every turn while put() still writes and the classifier-failure cascade's stale_read ignores staleness; the ceiling reasoning (unbounded window = never-expiring cache, 7200s still >> 840s real max run) also carries over in seconds. Every consumer updated in the same commit: - src/config.py: STALENESS_MINUTES_MIN/MAX -> STALENESS_SECONDS_MIN/MAX = 5/7200, staleness_minutes -> staleness_seconds: 1200, validator updated - src/dispatcher.py: drop the * 60 conversion (field is native seconds) - src/admin.py: _INT_KNOBS key/path/constants, _CONFIG_ALLOWLIST, _CONFIG_GET_ORDER, _runtime_state, error message template - admin/frontend/controls.html: note keys, tooltip, NUMBER_BOUNDS - config/config.yaml: staleness_seconds: 1200 - tests: test_admin_runtime/config/frontend/knob_coverage, plus stale comment in test_chat_completions - docs: admin-portal.md, evaluation.md, README.md config.local.yaml is gitignored and will be migrated separately.
564 lines
23 KiB
Python
564 lines
23 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 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"}
|
|
|
|
# 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,
|
|
}
|
|
assert body["default_flex_preference"] == {
|
|
"persisted": CFG.routing.default_flex_preference.value,
|
|
"runtime": dispatcher.cfg.routing.default_flex_preference.value,
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
@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"}
|
|
|
|
|
|
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
|