mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
12bd848c68
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.
288 lines
10 KiB
Python
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
|