Files
turnstone/tests/test_models_changed_event.py
T
Patrick Buckley 12bd848c68 feat(providers): anthropic-compatible lane for local /v1/messages servers
Add provider id "anthropic-compatible": the existing AnthropicProvider
pointed at Anthropic-compatible local servers (vLLM /v1/messages),
mirroring the openai/openai-compatible split. Registry-only — configured
via the admin Models tab or [models.*] toml, not exposed on the bare
--provider flag, so the CLI/server prod-URL defaults are unreachable for
the lane and real-Anthropic behavior is untouched.

Lane behavior (live-verified against vLLM 0.22.1rc1 + DeepSeek-V4-Flash):
- Capability defaults replace the Claude static table: token_param
  max_tokens, thinking_mode none, web_search/tool_search/vision off,
  reasoning replay on. vLLM rejects Anthropic server-side tool types
  (tools require input_schema) and ignores the thinking request param,
  so neither is sent; thinking blocks still stream back and round-trip
  through the native lane verbatim.
- Reasoning toggles via server_compat extra_body chat_template_kwargs
  (first-class vLLM request field; request-level keys beat server
  defaults). _build_thinking_and_kwargs forwards non-internal
  extra_params as SDK extra_body; thinking_budget_tokens stays internal.
- No temperature force: thinking_mode none skips the Claude-only
  temperature=1.0 requirement.

Admin UI: provider option + URL placeholder (base_url without /v1 — the
SDK appends /v1/messages); the server-compat section shows only the
extra-body field for the lane. thinking_mode round-trips through the
form dropdown for every provider except anthropic-compatible, where it
stays in the raw capabilities JSON — the edit-load lift and save restore
use the same predicate so stored overrides are never silently dropped.

Docs: architecture.md gains the lane subsection incl. verified quirks
(thinking param dropped by vLLM; stop_sequences cut inside thinking and
report end_turn; usage has no cache fields; images need a multimodal
model; mid-conversation system turns are per-model opt-in).

Negative-tested: removing the _INTERNAL_EXTRA_PARAMS exclusion fails
test_internal_keys_not_leaked; the live test drives a streamed turn with
the chat_template_kwargs toggle and asserts no reasoning deltas.
2026-06-11 20:27:29 -07:00

288 lines
10 KiB
Python

"""``models_changed`` SSE fanout coverage.
The console pushes a ``models_changed`` cluster event whenever a model
definition is created / updated / deleted / reloaded, or whenever a
setting in :data:`turnstone.console.server._MODEL_AFFECTING_SETTING_KEYS`
is updated or reset. Connected browsers refetch ``/v1/api/models`` on
receipt so the home composer dropdown + admin Models → Roles sub-tab
reflect alias edits without a manual reload.
These tests pin two contracts:
- every model-definition CRUD path emits exactly one ``models_changed``
fanout (so the browser stays in sync with the DB);
- settings PUT / DELETE only emit the fanout when the key is
model-affecting — unrelated keys (e.g. ``session.retention_days``)
must not trigger spurious dropdown re-renders across the cluster.
"""
from __future__ import annotations
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 (
_MODEL_AFFECTING_SETTING_KEYS,
admin_create_model_definition,
admin_delete_model_definition,
admin_delete_setting,
admin_model_reload,
admin_update_model_definition,
admin_update_setting,
)
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def storage(tmp_path: Any) -> SQLiteBackend:
return SQLiteBackend(str(tmp_path / "models_changed.db"))
def _seed(storage: SQLiteBackend, *, definition_id: str, alias: str) -> None:
storage.create_model_definition(
definition_id=definition_id,
alias=alias,
model="model-x",
provider="openai-compatible",
base_url="http://localhost:8000/v1",
api_key="sk-test",
context_window=8192,
capabilities="{}",
enabled=True,
created_by="admin",
)
def _make_client(storage: SQLiteBackend) -> tuple[TestClient, MagicMock]:
"""Build a TestClient + return the stub collector for assertion.
Wires the four model-definition CRUD/reload routes plus the two
settings mutation routes. Collector is a MagicMock so each
``emit_models_changed`` call lands as a recorded call without
spinning up the full SSE listener queue.
"""
app = Starlette(
routes=[
Route(
"/v1/api/admin/model-definitions",
admin_create_model_definition,
methods=["POST"],
),
Route(
"/v1/api/admin/model-definitions/reload",
admin_model_reload,
methods=["POST"],
),
Route(
"/v1/api/admin/model-definitions/{definition_id}",
admin_update_model_definition,
methods=["PUT"],
),
Route(
"/v1/api/admin/model-definitions/{definition_id}",
admin_delete_model_definition,
methods=["DELETE"],
),
Route(
"/v1/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/v1/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.auth_storage = storage
app.state.coord_registry = None # CRUD endpoints handle this gracefully
collector = MagicMock()
collector.get_all_nodes.return_value = []
app.state.collector = collector
app.state.proxy_client = MagicMock()
app.state.config_store = MagicMock()
client = TestClient(app)
client.headers.update(
{
"X-Test-User": "admin",
"X-Test-Perms": "admin.models,admin.settings",
}
)
return client, collector
# ---------------------------------------------------------------------------
# Model-definition CRUD endpoints fan out ``models_changed``
# ---------------------------------------------------------------------------
def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
client, collector = _make_client(storage)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "fast",
"model": "fast-model",
"provider": "openai-compatible",
"base_url": "http://localhost:9000/v1",
"api_key": "sk-x",
"context_window": 4096,
},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_create_accepts_anthropic_compatible_provider(storage: SQLiteBackend) -> None:
"""anthropic-compatible passes the _MODEL_PROVIDERS enum check."""
client, collector = _make_client(storage)
resp = client.post(
"/v1/api/admin/model-definitions",
json={
"alias": "vllm-messages",
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "anthropic-compatible",
"base_url": "http://localhost:8000",
"api_key": "dummy",
"context_window": 131072,
},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.put(
"/v1/api/admin/model-definitions/m1",
json={"model": "swapped-model"},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_update_with_empty_body_does_not_emit(storage: SQLiteBackend) -> None:
"""Empty-body PUT writes no rows + skips the registry refresh — no
SSE fanout either, since nothing actually changed."""
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.put("/v1/api/admin/model-definitions/m1", json={})
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0
def test_delete_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.delete("/v1/api/admin/model-definitions/m1")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_reload_emits_models_changed(storage: SQLiteBackend) -> None:
_seed(storage, definition_id="m1", alias="local")
client, collector = _make_client(storage)
resp = client.post("/v1/api/admin/model-definitions/reload")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
# ---------------------------------------------------------------------------
# Settings PUT / DELETE only emit for model-affecting keys
# ---------------------------------------------------------------------------
# Pinned snapshot of the role-related keys we expect the allowlist to
# cover today. The frozenset itself is asserted further down so a
# stray addition doesn't silently bypass coverage.
_EXPECTED_AFFECTING_KEYS = frozenset(
{
"model.default_alias",
"model.task_alias",
"model.task_effort",
"coordinator.model_alias",
"coordinator.reasoning_effort",
"judge.model",
"channels.default_model_alias",
}
)
def _value_for_key(key: str) -> str:
"""Return a registry-valid value for ``key``.
``reasoning_effort`` keys have a fixed choice list; alias-shaped
keys accept arbitrary strings. Avoids per-key custom payloads.
"""
if key.endswith("reasoning_effort") or key.endswith("task_effort"):
return "low"
return "anything"
def test_affecting_keys_set_matches_expected() -> None:
"""Lock in the allowlist so an unintentional removal is caught."""
assert _MODEL_AFFECTING_SETTING_KEYS == _EXPECTED_AFFECTING_KEYS
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
def test_settings_put_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
client, collector = _make_client(storage)
resp = client.put(
f"/v1/api/admin/settings/{key}",
json={"value": _value_for_key(key)},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
@pytest.mark.parametrize("key", sorted(_EXPECTED_AFFECTING_KEYS))
def test_settings_delete_emits_for_model_affecting_key(storage: SQLiteBackend, key: str) -> None:
client, collector = _make_client(storage)
# Seed a row so DELETE has something to remove (otherwise 404).
client.put(
f"/v1/api/admin/settings/{key}",
json={"value": _value_for_key(key)},
)
collector.emit_models_changed.reset_mock()
resp = client.delete(f"/v1/api/admin/settings/{key}")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 1
def test_settings_put_does_not_emit_for_unrelated_key(
storage: SQLiteBackend,
) -> None:
"""Updating a non-model setting (here: a session retention knob)
must not trigger a cluster-wide dropdown refresh."""
client, collector = _make_client(storage)
resp = client.put(
"/v1/api/admin/settings/session.retention_days",
json={"value": 30},
)
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0
def test_settings_delete_does_not_emit_for_unrelated_key(
storage: SQLiteBackend,
) -> None:
client, collector = _make_client(storage)
client.put(
"/v1/api/admin/settings/session.retention_days",
json={"value": 30},
)
collector.emit_models_changed.reset_mock()
resp = client.delete("/v1/api/admin/settings/session.retention_days")
assert resp.status_code == 200, resp.text
assert collector.emit_models_changed.call_count == 0