Files
turnstone/tests/test_admin_calibrate_endpoint.py
Patrick Buckley 33ace975d2 feat(models): default-deny governance and admin UI for per-alias backend auth
Follow-up to the per-alias Entra OBO/app-identity backend auth: the
console write path now applies default-deny field classification, the
admin shelf gains full backend-auth support, and the session/registry
rebind machinery is hardened for config changes landing under live
sessions.

Console write gate:
- Default-deny classification: any non-neutral change to a row that is
  or becomes dynamic requires admin.mcp plus validation; the provably
  auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a
  live-schema classification test forces every future column to be
  classified. The derivation is a pure function (_derive_auth_gate)
  with unit-pinned exclusivity invariants.
- Two-tier validation mirroring the MCP oauth_obo validator: the row
  tier (audience allow-list) runs on every gated write; the posture
  tier (OIDC configured, token store present) runs on pair changes and
  on enable-arming.
- Pure-disable carve-out: disabling a dynamic row is de-escalation and
  is never blocked — admin.models suffices and validation is skipped,
  including for rows with corrupt or skewed stored values.
- Capabilities are compared canonically (key order, integral floats),
  the audience compare normalizes both sides, and staging an audience
  on a static row is refused on both write twins.
- Calibrate writes the capabilities column under an enforced
  confinement invariant with a compare-and-swap persist.

Admin shelf:
- Backend-auth section with a per-open constraints fetch
  (GET /model-definitions/auth-constraints: audience allow-list, grant
  profile, dynamic modes), datalist audience suggestions,
  server-defined modes preserved on round-trip, and permission-aware
  visibility built on cache-skew-safe helpers shared through auth.js.
- Refused live-registry swaps surface as an amber registry_warning on
  the write, delete, reload, and calibrate responses; audit rows carry
  auth_gated / auth_disarmed markers visible in the audit view.

Registry and sessions:
- The encryption-key requirement for dynamic auth is enforced inside
  ModelRegistry.reload() itself — nodes refuse with 503 and the
  console records coord_registry_error — and reload bumps the
  generation before the map swap so a racing reader can never pair a
  stale generation with new maps.
- resolve()/resolve_binding() return the generation from inside the
  registry lock; sessions rebind per send on generation change with
  atomic client/provider/config commits, fallback-first handling of
  removed or unconstructable aliases, and judge/limiter resets only
  when the binding actually changed.
- Mint refusals record per-user causes surfaced in the per-turn
  heartbeat logs; misconfiguration warnings are deduplicated with
  bounded state.

Verification: 10417 tests (99 added on this branch), a 71-scenario
browser harness over the real admin shelf, and a live rfc8693
token-exchange e2e run (MCP legs verified end to end; the model-leg
scope gap is tracked as #955 under a narrow known-gap signature).

Closes #950.
2026-08-03 20:11:28 -07:00

551 lines
20 KiB
Python

"""Console reranker-calibration endpoint + calibrate-on-detect.
Covers the two server paths added in Phase 3:
- ``POST /api/admin/model-definitions/{id}/calibrate`` — probes a saved
reranker, persists the three calibration fields onto its capabilities, and
returns a verdict; a calibration failure is graceful (no 500, nothing
persisted).
- ``POST /api/admin/model-definitions/detect`` with ``supports_rerank`` —
calibrates the /rerank endpoint *directly* (a reranker's base_url is its full
/rerank path, which the ``/v1/models`` probe can't reach), so the probe is
skipped and the calibration round-trip IS the reachability check: success
autopopulates the three fields, failure reports ``reachable: False``. A
non-rerank detect still probes ``/v1/models`` and takes no calibration path.
``calibrate_model`` is monkeypatched (it needs a live /rerank endpoint).
Mirrors the TestClient wiring in test_admin_model_registry_refresh.py.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import _AuthMiddleware
from turnstone.console.server import (
admin_calibrate_model_definition,
admin_detect_model,
)
from turnstone.core.model_registry import ModelConfig, ModelRegistry
from turnstone.core.rerank_calibrate import CalibrationResult
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path: Any) -> SQLiteBackend:
return SQLiteBackend(str(tmp_path / "models.db"))
def _seed_reranker(
storage: SQLiteBackend,
*,
definition_id: str = "r1",
alias: str = "reranker",
base_url: str = "http://localhost:9999/rerank",
caps: dict[str, Any] | None = None,
) -> None:
storage.create_model_definition(
definition_id=definition_id,
alias=alias,
model="bge-reranker",
provider="openai-compatible",
base_url=base_url,
api_key="sk-test",
context_window=0,
capabilities=json.dumps(caps if caps is not None else {"supports_rerank": True}),
created_by="admin",
)
def _make_registry(alias: str = "reranker") -> ModelRegistry:
return ModelRegistry(
{
alias: ModelConfig(
alias=alias,
base_url="http://localhost:9999/rerank",
api_key="sk-test",
model="bge-reranker",
context_window=0,
provider="openai-compatible",
source="db",
capabilities={"supports_rerank": True},
)
},
default=alias,
)
def _result(*, separated: bool, threshold: float | None) -> CalibrationResult:
return CalibrationResult(
model="bge-reranker",
raw_scale="logit (sigmoid-normalised)",
separated=separated,
suggested_threshold=threshold,
relevant_min=0.7,
relevant_max=0.95,
irrelevant_min=0.05,
irrelevant_max=0.3,
n_relevant=18,
n_irrelevant=306,
)
def _make_client(storage: SQLiteBackend, registry: ModelRegistry | None) -> TestClient:
app = Starlette(
routes=[
Route(
"/v1/api/admin/model-definitions/{definition_id}/calibrate",
admin_calibrate_model_definition,
methods=["POST"],
),
Route(
"/v1/api/admin/model-definitions/detect",
admin_detect_model,
methods=["POST"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.auth_storage = storage
app.state.coord_registry = registry
app.state.collector = MagicMock()
app.state.collector.get_all_nodes.return_value = []
app.state.proxy_client = MagicMock()
app.state.config_store = MagicMock()
app.state.config_store.get.return_value = ""
client = TestClient(app)
client.headers.update({"X-Test-User": "admin", "X-Test-Perms": "admin.models"})
return client
def _stub_calibrate(monkeypatch: pytest.MonkeyPatch, result: CalibrationResult) -> None:
monkeypatch.setattr(
"turnstone.core.rerank_calibrate.calibrate_model",
lambda base_url, model, api_key, *, instruction="", timeout=60.0: result,
)
# ---------------------------------------------------------------------------
# Calibrate endpoint
# ---------------------------------------------------------------------------
def test_calibrate_persists_and_returns_verdict(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_reranker(storage)
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.42))
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["separated"] is True
assert body["suggested_threshold"] == 0.42
assert body["applied"] is True
assert body["error"] == ""
assert body["relevant"] == [0.7, 0.95]
assert body["irrelevant"] == [0.05, 0.3]
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert caps["rerank_threshold"] == 0.42
assert caps["rerank_scale"] == "logit (sigmoid-normalised)"
assert caps["rerank_separated"] is True
assert caps["supports_rerank"] is True # merge, not replace
def test_concurrent_capabilities_put_survives_calibrate(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The merge bases itself on a persist-time re-read, not the pre-probe
snapshot."""
_seed_reranker(storage, caps={"supports_rerank": True})
def _calibrate_and_race(
base_url: str,
model: str,
api_key: str,
*,
instruction: str = "",
timeout: float = 60.0,
) -> CalibrationResult:
# Mid-probe writer: a capabilities PUT lands while the probe runs.
storage.update_model_definition(
"r1",
capabilities=json.dumps(
{"supports_rerank": True, "server_compat": {"extra_body": {"x": 1}}}
),
)
return _result(separated=True, threshold=0.45)
monkeypatch.setattr("turnstone.core.rerank_calibrate.calibrate_model", _calibrate_and_race)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
assert resp.json()["applied"] is True
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert caps["server_compat"] == {"extra_body": {"x": 1}}
assert caps["rerank_threshold"] == 0.45
assert caps["rerank_separated"] is True
def test_calibrate_cas_retries_when_write_lands_between_reread_and_persist(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The persist is conditional: a missed compare re-merges onto the
newer value."""
_seed_reranker(storage, caps={"supports_rerank": True})
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.45))
real_update = storage.update_model_definition
state = {"interleaved": False}
def _update_with_interleaved_writer(definition_id: str, **kwargs: Any) -> bool:
if not state["interleaved"]:
state["interleaved"] = True
# Lands after the handler's re-read + merge, before its persist.
real_update(
definition_id,
capabilities=json.dumps(
{"supports_rerank": True, "server_compat": {"extra_body": {"x": 2}}}
),
)
return real_update(definition_id, **kwargs)
monkeypatch.setattr(storage, "update_model_definition", _update_with_interleaved_writer)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
assert resp.json()["applied"] is True
assert state["interleaved"]
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert caps["server_compat"] == {"extra_body": {"x": 2}}
assert caps["rerank_threshold"] == 0.45
assert caps["rerank_separated"] is True
def test_calibrate_yields_409_under_sustained_concurrent_writes(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Retries are bounded: under sustained pressure the competing writes
win, not the calibrate."""
_seed_reranker(storage, caps={"supports_rerank": True})
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.45))
real_update = storage.update_model_definition
state = {"n": 0}
def _update_with_persistent_writer(definition_id: str, **kwargs: Any) -> bool:
if "expected_capabilities" in kwargs:
# A different value before every attempt, so each fresh
# re-read is stale by persist time.
state["n"] += 1
real_update(
definition_id,
capabilities=json.dumps({"supports_rerank": True, "rev": state["n"]}),
)
return real_update(definition_id, **kwargs)
monkeypatch.setattr(storage, "update_model_definition", _update_with_persistent_writer)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 409, resp.text
assert "concurrently" in resp.json()["error"]
assert state["n"] == 3 # the retry budget
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert "rerank_threshold" not in caps
assert caps["rev"] == 3
def test_row_deleted_mid_probe_is_not_found(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_reranker(storage)
def _calibrate_and_delete(
base_url: str,
model: str,
api_key: str,
*,
instruction: str = "",
timeout: float = 60.0,
) -> CalibrationResult:
storage.delete_model_definition("r1")
return _result(separated=True, threshold=0.45)
monkeypatch.setattr("turnstone.core.rerank_calibrate.calibrate_model", _calibrate_and_delete)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 404, resp.text
assert storage.get_model_definition("r1") is None
def test_calibrate_refuses_merge_that_writes_out_of_band_keys(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A merge reaching a non-calibration key is refused, not smuggled past
the admin.mcp gate this endpoint lacks."""
_seed_reranker(storage)
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.42))
stored_before = storage.get_model_definition("r1")["capabilities"]
def _rogue_merge(raw_caps: Any, result: CalibrationResult) -> str:
caps = json.loads(raw_caps or "{}")
caps["rerank_threshold"] = 0.42
caps["server_compat"] = {"api_surface": "responses"}
return json.dumps(caps)
monkeypatch.setattr("turnstone.core.rerank_calibrate.merge_calibration_into_caps", _rogue_merge)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 500, resp.text
assert "server_compat" in resp.json()["error"]
assert storage.get_model_definition("r1")["capabilities"] == stored_before
def test_calibrate_no_separation_persists_marker(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No clean split still persists the marker (rerank_scale) + separated=False
so the chip can warn; threshold is 0.0 (the floor logic disables)."""
_seed_reranker(storage)
_stub_calibrate(monkeypatch, _result(separated=False, threshold=None))
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["separated"] is False
assert body["applied"] is True
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert caps["rerank_scale"] == "logit (sigmoid-normalised)"
assert caps["rerank_separated"] is False
assert caps["rerank_threshold"] == 0.0
def test_calibrate_failure_is_graceful(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""calibrate raising (unreachable / non-reranker) -> verdict with error,
no 500, model capabilities untouched."""
_seed_reranker(storage)
def _boom(base_url, model, api_key, *, instruction="", timeout=60.0):
raise RuntimeError("connection refused")
monkeypatch.setattr("turnstone.core.rerank_calibrate.calibrate_model", _boom)
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["applied"] is False
assert body["separated"] is False
assert "connection refused" in body["error"]
caps = json.loads(storage.get_model_definition("r1")["capabilities"])
assert "rerank_scale" not in caps # nothing persisted on failure
def test_calibrate_unknown_definition_404(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.42))
client = _make_client(storage, None)
resp = client.post("/v1/api/admin/model-definitions/nope/calibrate")
assert resp.status_code == 404
def test_calibrate_no_base_url_graceful(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_reranker(storage, base_url="")
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.42))
client = _make_client(storage, _make_registry())
resp = client.post("/v1/api/admin/model-definitions/r1/calibrate")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["applied"] is False
assert "base_url" in body["error"]
# ---------------------------------------------------------------------------
# Calibrate-on-detect
# ---------------------------------------------------------------------------
def _stub_probe(monkeypatch: pytest.MonkeyPatch, result: dict[str, Any]) -> None:
monkeypatch.setattr(
"turnstone.core.model_registry.probe_model_endpoint",
lambda provider, base_url, api_key, target_model="": dict(result),
)
def test_detect_reranker_calibrates_directly_skipping_probe(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A reranker detect must NOT touch /v1/models (its base_url is the full
/rerank path) — it calibrates directly and autopopulates the three fields."""
probe_called: list[Any] = []
monkeypatch.setattr(
"turnstone.core.model_registry.probe_model_endpoint",
lambda *a, **kw: probe_called.append(a) or {"reachable": True},
)
_stub_calibrate(monkeypatch, _result(separated=True, threshold=0.33))
client = _make_client(storage, None)
resp = client.post(
"/v1/api/admin/model-definitions/detect",
json={
"provider": "openai-compatible",
"base_url": "http://localhost:9999/rerank",
"model": "bge-reranker",
"supports_rerank": True,
},
)
assert resp.status_code == 200, resp.text
assert probe_called == [] # /v1/models never probed for a reranker
body = resp.json()
assert body["reachable"] is True
assert body["available_models"] == []
caps = body.get("capabilities", {})
assert caps["supports_rerank"] is True
assert caps["rerank_threshold"] == 0.33
assert caps["rerank_scale"] == "logit (sigmoid-normalised)"
assert caps["rerank_separated"] is True
def test_detect_reranker_no_separation_notes_but_reachable(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Reachable reranker with no clean split: fields still populate (scale
marker + separated=False, threshold 0) and a note warns; still reachable."""
_stub_calibrate(monkeypatch, _result(separated=False, threshold=None))
client = _make_client(storage, None)
resp = client.post(
"/v1/api/admin/model-definitions/detect",
json={
"provider": "openai-compatible",
"base_url": "http://localhost:9999/rerank",
"model": "bge-reranker",
"supports_rerank": True,
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["reachable"] is True
caps = body["capabilities"]
assert caps["rerank_separated"] is False
assert caps["rerank_threshold"] == 0.0
assert "no clean separation" in body["rerank_calibration_note"]
def test_detect_reranker_calibration_failure_is_unreachable(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Can't calibrate -> it isn't a working reranker: reachable False + error,
no capabilities (there is no independent /v1/models reachability signal)."""
def _boom(base_url, model, api_key, *, instruction="", timeout=60.0):
raise RuntimeError("not a reranker")
monkeypatch.setattr("turnstone.core.rerank_calibrate.calibrate_model", _boom)
client = _make_client(storage, None)
resp = client.post(
"/v1/api/admin/model-definitions/detect",
json={
"provider": "openai-compatible",
"base_url": "http://localhost:9999/rerank",
"model": "bge-reranker",
"supports_rerank": True,
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["reachable"] is False
assert not body.get("capabilities")
assert "not a reranker" in body["error"]
def test_detect_reranker_requires_base_url(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A reranker has no provider-default URL — an empty base_url is a clear 400
(not a probe against some default host) and never attempts calibration."""
called: list[Any] = []
monkeypatch.setattr(
"turnstone.core.rerank_calibrate.calibrate_model",
lambda *a, **kw: called.append(a) or _result(separated=True, threshold=0.5),
)
client = _make_client(storage, None)
resp = client.post(
"/v1/api/admin/model-definitions/detect",
json={
"provider": "openai-compatible",
"base_url": "",
"model": "bge-reranker",
"supports_rerank": True,
},
)
assert resp.status_code == 400, resp.text
assert "/rerank" in resp.json()["error"]
assert called == [] # never attempted calibration with an empty url
def test_detect_non_reranker_skips_calibration(
storage: SQLiteBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A normal detect (no supports_rerank) must not call calibrate at all —
no extra round-trip / slowdown for chat models."""
_stub_probe(
monkeypatch,
{
"reachable": True,
"available_models": ["gpt-x"],
"context_window": 128000,
"error": None,
},
)
called: list[Any] = []
monkeypatch.setattr(
"turnstone.core.rerank_calibrate.calibrate_model",
lambda *a, **kw: called.append(a) or _result(separated=True, threshold=0.5),
)
client = _make_client(storage, None)
resp = client.post(
"/v1/api/admin/model-definitions/detect",
json={
"provider": "openai-compatible",
"base_url": "http://localhost:8000/v1",
"model": "gpt-x",
},
)
assert resp.status_code == 200, resp.text
assert called == [] # calibrate never invoked
body = resp.json()
assert "capabilities" not in body or not body["capabilities"]
assert body["context_window"] == 128000 # ordinary detect unchanged