bb9bed82e1
ModelRole collapses to "primary": every role (main, tool selector, memory
agents, subagents, summarizer) resolves to the snapshot's frozen primary
model, per design 6.1/8.3 — users typically configure a single usable LLM,
so compile-time auxiliary bindings were bypassing run snapshots and
mis-attributing usage. Legacy auxiliary keys in stored snapshots, registry
JSON, and thread metadata are tolerated on read and dropped.
BREAKING CHANGE: ThreadModelSelection no longer carries an auxiliary ref;
snapshot selection_hash is computed over {primary, reasoning_effort} only;
ConfigurableModelMiddleware(role="auxiliary") is rejected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
941 lines
32 KiB
Python
941 lines
32 KiB
Python
"""HTTP API contract tests for the model registry (design doc 9.1-9.3, 9.5).
|
|
|
|
Covers the Config API (GET/PUT registry, credential rotation, selector),
|
|
the eight section 9.2 atomic save-time checks, snapshot creation/binding/
|
|
deletion state machines, the unified error payload, and the OpenAPI export.
|
|
Authentication itself is covered exhaustively in test_delegation_auth.py;
|
|
these tests mint valid delegations per request.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
import pytest
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from starlette.applications import Starlette
|
|
from starlette.testclient import TestClient
|
|
|
|
from EvoScientist.model_registry.adapters import find_adapter_spec
|
|
from EvoScientist.model_registry.auth import BffAuthenticator
|
|
from EvoScientist.model_registry.endpoint_policy import EndpointPolicy
|
|
from EvoScientist.model_registry.hashing import configuration_hash
|
|
from EvoScientist.model_registry.http_api import (
|
|
ApiServices,
|
|
build_openapi_document,
|
|
model_registry_routes,
|
|
)
|
|
from EvoScientist.model_registry.platform import (
|
|
DelegationPublicKey,
|
|
PlatformConfigError,
|
|
)
|
|
from EvoScientist.model_registry.resolver import ModelRegistryResolver
|
|
from EvoScientist.model_registry.schemas import (
|
|
CredentialWrite,
|
|
DevelopmentEndpoint,
|
|
RegistryV4,
|
|
)
|
|
from EvoScientist.model_registry.snapshots import SnapshotService
|
|
from EvoScientist.model_registry.store import ModelRuntimeStore
|
|
|
|
SERVICE_TOKEN = "bff-service-token"
|
|
SECRET = "sk-live-9876abcd"
|
|
|
|
_PRIVATE_KEY = ec.generate_private_key(ec.SECP256R1())
|
|
_PRIVATE_PEM = _PRIVATE_KEY.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
)
|
|
_PUBLIC_PEM = _PRIVATE_KEY.public_key().public_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
)
|
|
|
|
OPENAPI_PATH = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "EvoScientist"
|
|
/ "model_registry"
|
|
/ "openapi.json"
|
|
)
|
|
|
|
|
|
# --- registry fixtures (mirrors test_snapshots.py) ---------------------------
|
|
|
|
|
|
def _model_runtime(**overrides):
|
|
payload = {
|
|
"limit_mode": "combined",
|
|
"context_window_tokens": 1048576,
|
|
"max_input_tokens": None,
|
|
"max_output_tokens": 32768,
|
|
"min_effective_input_tokens": 8192,
|
|
"fixed_system_reserve_tokens": 4096,
|
|
"fixed_tools_reserve_tokens": 8192,
|
|
"fixed_attachments_reserve_tokens": 4096,
|
|
"limits_status": "confirmed",
|
|
"limits_source": "provider",
|
|
"temperature": None,
|
|
"top_p": None,
|
|
"reasoning_effort": "auto",
|
|
"declared_capabilities": {
|
|
"tools": True,
|
|
"vision": False,
|
|
"structured_output": True,
|
|
},
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def _zhipu_provider(**overrides):
|
|
provider = {
|
|
"id": "zhipu-glm",
|
|
"name": "Zhipu GLM",
|
|
"adapter": "openai-compatible",
|
|
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
"auth": {"mode": "api_key", "credential_id": "zhipu-primary"},
|
|
"enabled": True,
|
|
"runtime": {
|
|
"timeout_seconds": 120,
|
|
"max_retries": 2,
|
|
"default_temperature": 0.7,
|
|
"default_top_p": 0.95,
|
|
"default_reasoning_effort": "auto",
|
|
},
|
|
"models": [
|
|
{
|
|
"key": "glm-5.2",
|
|
"name": "GLM-5.2",
|
|
"upstream_model_id": "glm-5.2",
|
|
"enabled": True,
|
|
"runtime": _model_runtime(),
|
|
}
|
|
],
|
|
}
|
|
provider.update(overrides)
|
|
return provider
|
|
|
|
|
|
def _ollama_provider(**overrides):
|
|
provider = {
|
|
"id": "local-ollama",
|
|
"name": "Local Ollama",
|
|
"adapter": "ollama",
|
|
"base_url": "http://localhost:11434",
|
|
"auth": {"mode": "none", "credential_id": None},
|
|
"enabled": True,
|
|
"runtime": {"timeout_seconds": 120, "max_retries": 2},
|
|
"models": [
|
|
{
|
|
"key": "qwen3",
|
|
"name": "Qwen3",
|
|
"upstream_model_id": "qwen3",
|
|
"enabled": True,
|
|
"runtime": _model_runtime(),
|
|
}
|
|
],
|
|
}
|
|
provider.update(overrides)
|
|
return provider
|
|
|
|
|
|
def _registry_payload():
|
|
return {
|
|
"version": 4,
|
|
"revision": 1,
|
|
"state": "bootstrap",
|
|
"defaults": {
|
|
"primary": {"provider_id": "zhipu-glm", "model_key": "glm-5.2"},
|
|
},
|
|
"providers": [_zhipu_provider(), _ollama_provider()],
|
|
}
|
|
|
|
|
|
def _verify(store, registry, provider_id, model_key):
|
|
provider = registry.find_provider(provider_id)
|
|
model = provider.find_model(model_key)
|
|
spec = find_adapter_spec(provider.adapter, model.upstream_model_id)
|
|
store.record_model_verification(
|
|
provider_id=provider_id,
|
|
model_key=model_key,
|
|
configuration_hash=configuration_hash(provider, model),
|
|
credential_revision=1 if provider.auth.credential_id else 0,
|
|
adapter_spec_revision=spec.spec_revision,
|
|
result="passed",
|
|
verified_capabilities={
|
|
"tools": True,
|
|
"vision": False,
|
|
"structured_output": True,
|
|
},
|
|
)
|
|
|
|
|
|
# --- app fixtures --------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
return ModelRuntimeStore(config_dir=tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def services(store):
|
|
resolver = ModelRegistryResolver(store)
|
|
return ApiServices(
|
|
store=store,
|
|
resolver=resolver,
|
|
snapshot_service=SnapshotService(store, resolver),
|
|
endpoint_policy=EndpointPolicy(
|
|
[
|
|
DevelopmentEndpoint(
|
|
id="local-ollama",
|
|
url="http://localhost:11434",
|
|
label="Local Ollama",
|
|
)
|
|
]
|
|
),
|
|
authenticator=BffAuthenticator(
|
|
service_token=SERVICE_TOKEN,
|
|
service_token_hash=None,
|
|
delegation_keys=(
|
|
DelegationPublicKey(deployment_id="webui-1", public_key=_PUBLIC_PEM),
|
|
),
|
|
jti_store=store,
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(services):
|
|
app = Starlette(routes=model_registry_routes(lambda: services))
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def active_store(store):
|
|
registry = store.save_registry(
|
|
expected_revision=1,
|
|
registry=RegistryV4.model_validate(_registry_payload()),
|
|
credential_writes=[
|
|
CredentialWrite(credential_id="zhipu-primary", secret_value=SECRET)
|
|
],
|
|
)
|
|
assert registry.state == "active"
|
|
_verify(store, registry, "zhipu-glm", "glm-5.2")
|
|
_verify(store, registry, "local-ollama", "qwen3")
|
|
return store
|
|
|
|
|
|
@pytest.fixture
|
|
def active_client(client, active_store):
|
|
return client
|
|
|
|
|
|
# --- auth helpers --------------------------------------------------------------
|
|
|
|
|
|
def _headers(scopes, *, thread_id=None):
|
|
now = int(time.time())
|
|
claims = {
|
|
"iss": "WebUI",
|
|
"aud": "EvoScientist",
|
|
"sub": "user-1",
|
|
"scopes": list(scopes),
|
|
"deployment_id": "webui-1",
|
|
"iat": now,
|
|
"exp": now + 30,
|
|
"jti": uuid.uuid4().hex,
|
|
}
|
|
if thread_id is not None:
|
|
claims["thread_id"] = thread_id
|
|
return {
|
|
"Authorization": f"Bearer {SERVICE_TOKEN}",
|
|
"X-Evo-Actor": jwt.encode(claims, _PRIVATE_PEM, algorithm="ES256"),
|
|
}
|
|
|
|
|
|
def _admin_headers(**kwargs):
|
|
return _headers(["model_config:read", "model_config:write"], **kwargs)
|
|
|
|
|
|
def _run_headers(thread_id="thread-1"):
|
|
return _headers(["run:create"], thread_id=thread_id)
|
|
|
|
|
|
# --- GET /api/model-registry ----------------------------------------------------
|
|
|
|
|
|
def test_get_registry_response_structure(active_client):
|
|
response = active_client.get("/api/model-registry", headers=_admin_headers())
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert set(body) == {
|
|
"revision",
|
|
"registry",
|
|
"adapter_specs",
|
|
"credential_status",
|
|
"model_status",
|
|
"endpoint_policy",
|
|
}
|
|
assert body["revision"] == 2
|
|
assert body["registry"]["state"] == "active"
|
|
assert len(body["adapter_specs"]) == 6
|
|
status = body["credential_status"]
|
|
assert [entry["credential_id"] for entry in status] == ["zhipu-primary"]
|
|
assert status[0]["configured"] is True
|
|
assert status[0]["hint"] == "...abcd"
|
|
assert status[0]["updated_at"]
|
|
assert SECRET not in response.text
|
|
states = {item["model_ref"]["model_key"]: item for item in body["model_status"]}
|
|
assert states["glm-5.2"]["state"] == "enabled"
|
|
assert states["glm-5.2"]["selectable"] is True
|
|
assert states["qwen3"]["state"] == "enabled"
|
|
policy = body["endpoint_policy"]
|
|
assert policy["public_https_allowed"] is True
|
|
assert policy["development_endpoints"] == [
|
|
{"id": "local-ollama", "url": "http://localhost:11434", "label": "Local Ollama"}
|
|
]
|
|
|
|
|
|
def test_get_registry_marks_model_stale_after_credential_rotation(active_client):
|
|
rotate = active_client.put(
|
|
"/api/model-registry/credentials/zhipu-primary",
|
|
headers=_admin_headers(),
|
|
json={"operation": "replace", "secret_value": "rotated-secret-9999"},
|
|
)
|
|
assert rotate.status_code == 200
|
|
|
|
body = active_client.get("/api/model-registry", headers=_admin_headers()).json()
|
|
states = {item["model_ref"]["model_key"]: item for item in body["model_status"]}
|
|
# The full credential_revisions mapping must reach compute_availability:
|
|
# after rotation the glm model compares against revision 2 and goes stale.
|
|
assert states["glm-5.2"]["state"] == "verification_stale"
|
|
assert states["glm-5.2"]["selectable"] is False
|
|
assert states["glm-5.2"]["verification"]["status"] == "stale"
|
|
# mode=none models are unaffected by credential rotation.
|
|
assert states["qwen3"]["state"] == "enabled"
|
|
|
|
|
|
# --- PUT /api/model-registry -----------------------------------------------------
|
|
|
|
|
|
def _current_registry(active_client):
|
|
return active_client.get("/api/model-registry", headers=_admin_headers()).json()
|
|
|
|
|
|
def _put(active_client, registry, *, expected_revision=2, credential_writes=None):
|
|
payload = {
|
|
"expected_revision": expected_revision,
|
|
"registry": registry,
|
|
"credential_writes": credential_writes or [],
|
|
}
|
|
return active_client.put(
|
|
"/api/model-registry", headers=_admin_headers(), json=payload
|
|
)
|
|
|
|
|
|
def test_put_registry_success_returns_full_response(active_client):
|
|
current = _current_registry(active_client)
|
|
registry = current["registry"]
|
|
registry["providers"][0]["models"][0]["name"] = "GLM-5.2 Turbo"
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["revision"] == current["revision"] + 1
|
|
assert body["registry"]["providers"][0]["models"][0]["name"] == "GLM-5.2 Turbo"
|
|
states = {item["model_ref"]["model_key"]: item for item in body["model_status"]}
|
|
# The model name is not part of the configuration hash: still enabled.
|
|
assert states["glm-5.2"]["state"] == "enabled"
|
|
|
|
|
|
def test_put_registry_revision_conflict(active_client):
|
|
current = _current_registry(active_client)
|
|
response = _put(active_client, current["registry"], expected_revision=99)
|
|
assert response.status_code == 409
|
|
body = response.json()
|
|
assert body["code"] == "REGISTRY_REVISION_CONFLICT"
|
|
assert set(body) == {"code", "message", "details", "request_id"}
|
|
|
|
|
|
def test_put_registry_rejects_ssrf_endpoint(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["base_url"] = "http://127.0.0.1:9000/v1"
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "ENDPOINT_NOT_ALLOWED"
|
|
assert body["details"][0]["path"].startswith("providers[0].base_url")
|
|
|
|
|
|
def test_put_registry_rejects_unsupported_parameter(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
# The glm-5.2 contract caps temperature at 1.
|
|
registry["providers"][0]["models"][0]["runtime"]["temperature"] = 1.5
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "UNSUPPORTED_RUNTIME_PARAMETER"
|
|
assert "providers[0].models[0]" in body["details"][0]["path"]
|
|
|
|
|
|
def test_put_registry_rejects_unconfirmed_limits(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["models"][0]["runtime"]["limits_status"] = (
|
|
"needs_confirmation"
|
|
)
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "MODEL_LIMITS_UNCONFIRMED"
|
|
|
|
|
|
def test_put_registry_rejects_enabled_model_without_verification(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["models"].append(
|
|
{
|
|
"key": "glm-5.3",
|
|
"name": "GLM-5.3",
|
|
"upstream_model_id": "glm-5.3",
|
|
"enabled": True,
|
|
"runtime": _model_runtime(),
|
|
}
|
|
)
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "MODEL_NOT_AVAILABLE"
|
|
|
|
|
|
def test_put_registry_rejects_unsatisfiable_budget(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
# A disabled draft model still must satisfy the four-mode budget rule.
|
|
registry["providers"][0]["models"].append(
|
|
{
|
|
"key": "glm-draft",
|
|
"name": "GLM Draft",
|
|
"upstream_model_id": "glm-draft",
|
|
"enabled": False,
|
|
"runtime": _model_runtime(min_effective_input_tokens=1048576),
|
|
}
|
|
)
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "CONTEXT_BUDGET_UNSATISFIABLE"
|
|
|
|
|
|
def test_put_registry_rejects_unsupported_auth_mode(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][1]["auth"] = {"mode": "api_key", "credential_id": "some-key"}
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "AUTH_MODE_UNSUPPORTED"
|
|
|
|
|
|
def test_put_registry_rejects_capability_unsupported_by_adapter(active_client):
|
|
# Rule 8: declared capabilities must not exceed the adapter contract's
|
|
# protocol capabilities — the glm-5.2 contract declares vision=False.
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["models"][0]["runtime"]["declared_capabilities"][
|
|
"vision"
|
|
] = True
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "CAPABILITY_UNSUPPORTED_BY_ADAPTER"
|
|
assert "providers[0].models[0]" in body["details"][0]["path"]
|
|
|
|
|
|
def test_put_registry_rejects_missing_credential_reference(active_client):
|
|
# The api_key AuthSpec requires a credential: credential_id=None must
|
|
# fail with CREDENTIAL_NOT_CONFIGURED, not a verification-tuple miss.
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["auth"] = {"mode": "api_key", "credential_id": None}
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "CREDENTIAL_NOT_CONFIGURED"
|
|
assert "auth.credential_id" in body["details"][0]["path"]
|
|
|
|
|
|
def test_put_registry_rejects_defaults_to_disabled_model(active_client):
|
|
# With a bootstrap-state payload the schema-level check does not fire, so
|
|
# the store's rule-7 guard (enforced on every save) is what rejects it.
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["state"] = "bootstrap"
|
|
registry["providers"][0]["models"][0]["enabled"] = False
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "MODEL_DISABLED"
|
|
|
|
|
|
def test_put_registry_active_state_validates_defaults_in_schema(active_client):
|
|
# The same rule fires earlier (Pydantic) when the payload claims active.
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["models"][0]["enabled"] = False
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "VALIDATION_FAILED"
|
|
|
|
|
|
def test_put_registry_atomic_rollback_with_credential_writes(active_client, store):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["base_url"] = "http://127.0.0.1:9000/v1"
|
|
|
|
response = _put(
|
|
active_client,
|
|
registry,
|
|
credential_writes=[
|
|
{
|
|
"credential_id": "zhipu-primary",
|
|
"operation": "replace",
|
|
"secret_value": "should-not-persist",
|
|
}
|
|
],
|
|
)
|
|
assert response.status_code == 422
|
|
# Neither the credential write nor the registry update may survive.
|
|
assert store.current_credential_revision("zhipu-primary") == 1
|
|
assert store.load_registry().revision == 2
|
|
|
|
|
|
def test_put_registry_schema_validation_details(active_client):
|
|
registry = _current_registry(active_client)["registry"]
|
|
registry["providers"][0]["id"] = "INVALID ID!"
|
|
|
|
response = _put(active_client, registry)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "VALIDATION_FAILED"
|
|
assert body["details"]
|
|
assert all("path" in detail and "code" in detail for detail in body["details"])
|
|
|
|
|
|
def test_put_registry_bootstrap_flow_configure_test_enable(client, store):
|
|
# Step 1: save a draft registry — models disabled, defaults null, and the
|
|
# credential written in the same transaction (section 10 steps 5-7).
|
|
draft = _registry_payload()
|
|
draft["defaults"] = {"primary": None}
|
|
for provider in draft["providers"]:
|
|
for model in provider["models"]:
|
|
model["enabled"] = False
|
|
response = client.put(
|
|
"/api/model-registry",
|
|
headers=_admin_headers(),
|
|
json={
|
|
"expected_revision": 1,
|
|
"registry": draft,
|
|
"credential_writes": [
|
|
{
|
|
"credential_id": "zhipu-primary",
|
|
"operation": "replace",
|
|
"secret_value": "bootstrap-secret",
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["registry"]["state"] == "bootstrap"
|
|
assert body["revision"] == 2
|
|
assert "bootstrap-secret" not in response.text
|
|
states = {item["model_ref"]["model_key"]: item for item in body["model_status"]}
|
|
assert states["glm-5.2"]["state"] == "configured"
|
|
|
|
# Step 2: provider tests pass (Task 5b writes these records via the test
|
|
# endpoint; here they are seeded directly).
|
|
registry = store.load_registry()
|
|
_verify(store, registry, "zhipu-glm", "glm-5.2")
|
|
_verify(store, registry, "local-ollama", "qwen3")
|
|
|
|
# Step 3: enabling the verified models with defaults turns the registry
|
|
# active in the same commit.
|
|
enabled = body["registry"]
|
|
enabled["defaults"] = {
|
|
"primary": {"provider_id": "zhipu-glm", "model_key": "glm-5.2"},
|
|
}
|
|
for provider in enabled["providers"]:
|
|
for model in provider["models"]:
|
|
model["enabled"] = True
|
|
response = client.put(
|
|
"/api/model-registry",
|
|
headers=_admin_headers(),
|
|
json={"expected_revision": 2, "registry": enabled, "credential_writes": []},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["registry"]["state"] == "active"
|
|
states = {item["model_ref"]["model_key"]: item for item in body["model_status"]}
|
|
assert states["glm-5.2"]["state"] == "enabled"
|
|
|
|
|
|
def test_put_registry_malformed_json_is_400(active_client):
|
|
response = active_client.put(
|
|
"/api/model-registry",
|
|
headers={**_admin_headers(), "Content-Type": "application/json"},
|
|
content="{not json",
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json()["code"] == "INVALID_REQUEST"
|
|
|
|
|
|
# --- PUT /api/model-registry/credentials/{credential_id} ------------------------
|
|
|
|
|
|
def test_credential_rotation_creates_new_masked_revision(active_client):
|
|
response = active_client.put(
|
|
"/api/model-registry/credentials/zhipu-primary",
|
|
headers=_admin_headers(),
|
|
json={"operation": "replace", "secret_value": "rotated-secret-9999"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["credential_id"] == "zhipu-primary"
|
|
assert body["revision"] == 2
|
|
assert body["configured"] is True
|
|
assert body["hint"] == "...9999"
|
|
assert body["updated_at"]
|
|
assert "rotated-secret-9999" not in response.text
|
|
|
|
|
|
def test_credential_rotation_rejects_invalid_credential_id(active_client):
|
|
response = active_client.put(
|
|
"/api/model-registry/credentials/INVALID!!",
|
|
headers=_admin_headers(),
|
|
json={"operation": "replace", "secret_value": "whatever"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_credential_rotation_requires_write_scope(active_client):
|
|
response = active_client.put(
|
|
"/api/model-registry/credentials/zhipu-primary",
|
|
headers=_headers(["model_config:read"]),
|
|
json={"operation": "replace", "secret_value": "whatever"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
# --- GET /api/models -------------------------------------------------------------
|
|
|
|
|
|
def test_get_models_returns_only_selectable(active_client, store):
|
|
response = active_client.get("/api/models", headers=_headers(["model:select"]))
|
|
assert response.status_code == 200
|
|
models = response.json()["models"]
|
|
refs = {
|
|
(m["model_ref"]["provider_id"], m["model_ref"]["model_key"]) for m in models
|
|
}
|
|
assert refs == {("zhipu-glm", "glm-5.2"), ("local-ollama", "qwen3")}
|
|
glm = next(m for m in models if m["model_ref"]["model_key"] == "glm-5.2")
|
|
assert glm["name"] == "GLM-5.2"
|
|
assert glm["provider_name"] == "Zhipu GLM"
|
|
assert glm["effective_capabilities"] == {
|
|
"tools": True,
|
|
"vision": False,
|
|
"structured_output": True,
|
|
}
|
|
# The selector echoes the registry defaults so clients can label the
|
|
# `inherit` choice before the first turn.
|
|
assert response.json()["defaults"]["primary"] == {
|
|
"provider_id": "zhipu-glm",
|
|
"model_key": "glm-5.2",
|
|
}
|
|
|
|
# Disable qwen3: it leaves the selector.
|
|
registry = store.load_registry()
|
|
payload = registry.model_dump(mode="json")
|
|
payload["providers"][1]["models"][0]["enabled"] = False
|
|
store.save_registry(
|
|
expected_revision=registry.revision,
|
|
registry=RegistryV4.model_validate(payload),
|
|
)
|
|
models = active_client.get(
|
|
"/api/models", headers=_headers(["model:select"])
|
|
).json()["models"]
|
|
assert [m["model_ref"]["model_key"] for m in models] == ["glm-5.2"]
|
|
|
|
|
|
def test_get_models_requires_select_scope(active_client):
|
|
response = active_client.get("/api/models", headers=_headers(["run:create"]))
|
|
assert response.status_code == 403
|
|
|
|
|
|
# --- snapshot API -----------------------------------------------------------------
|
|
|
|
|
|
def _snapshot_body(**overrides):
|
|
body = {
|
|
"run_request_id": "req-1",
|
|
"thread_id": "thread-1",
|
|
"deployment_id": "webui-1",
|
|
"model_selection_revision": 4,
|
|
"primary": None,
|
|
}
|
|
body.update(overrides)
|
|
return body
|
|
|
|
|
|
def test_snapshot_create_201_and_idempotent_200(active_client):
|
|
first = active_client.post(
|
|
"/api/runtime-snapshots", headers=_run_headers(), json=_snapshot_body()
|
|
)
|
|
assert first.status_code == 201
|
|
body = first.json()
|
|
assert body["snapshot_id"]
|
|
assert body["registry_revision"] == 2
|
|
assert body["primary"]["provider_id"] == "zhipu-glm"
|
|
assert body["primary"]["adapter_spec_revision"] == 1
|
|
assert body["primary"]["runtime"]["max_output_tokens"] == 32768
|
|
assert SECRET not in first.text
|
|
|
|
replay = active_client.post(
|
|
"/api/runtime-snapshots", headers=_run_headers(), json=_snapshot_body()
|
|
)
|
|
assert replay.status_code == 200
|
|
assert replay.json()["snapshot_id"] == body["snapshot_id"]
|
|
|
|
|
|
def test_snapshot_create_conflicting_selection_is_409(active_client):
|
|
active_client.post(
|
|
"/api/runtime-snapshots", headers=_run_headers(), json=_snapshot_body()
|
|
)
|
|
conflict = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers(),
|
|
json=_snapshot_body(
|
|
primary={"provider_id": "zhipu-glm", "model_key": "glm-5.2"}
|
|
),
|
|
)
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["code"] == "RUN_REQUEST_CONFLICT"
|
|
|
|
|
|
def test_snapshot_create_requires_active_registry(client):
|
|
response = client.post(
|
|
"/api/runtime-snapshots", headers=_run_headers(), json=_snapshot_body()
|
|
)
|
|
assert response.status_code == 422
|
|
assert response.json()["code"] == "MODEL_REGISTRY_NOT_READY"
|
|
|
|
|
|
def test_snapshot_create_thread_mismatch_is_403(active_client):
|
|
response = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers("thread-1"),
|
|
json=_snapshot_body(thread_id="thread-2"),
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["code"] == "FORBIDDEN"
|
|
|
|
|
|
def test_snapshot_create_requires_thread_claim(active_client):
|
|
headers = _headers(["run:create"]) # no thread_id claim
|
|
response = active_client.post(
|
|
"/api/runtime-snapshots", headers=headers, json=_snapshot_body()
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_snapshot_create_unknown_model_is_404(active_client):
|
|
response = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers(),
|
|
json=_snapshot_body(primary={"provider_id": "zhipu-glm", "model_key": "nope"}),
|
|
)
|
|
assert response.status_code == 404
|
|
assert response.json()["code"] == "MODEL_NOT_FOUND"
|
|
|
|
|
|
def _create_snapshot(active_client, **overrides):
|
|
response = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers(),
|
|
json=_snapshot_body(**overrides),
|
|
)
|
|
assert response.status_code == 201
|
|
return response.json()["snapshot_id"]
|
|
|
|
|
|
def test_snapshot_bind_state_machine(active_client):
|
|
snapshot_id = _create_snapshot(active_client)
|
|
bound = active_client.post(
|
|
f"/api/runtime-snapshots/{snapshot_id}/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
assert bound.status_code == 200
|
|
assert bound.json()["snapshot_id"] == snapshot_id
|
|
|
|
again = active_client.post(
|
|
f"/api/runtime-snapshots/{snapshot_id}/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
assert again.status_code == 200
|
|
|
|
conflict = active_client.post(
|
|
f"/api/runtime-snapshots/{snapshot_id}/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-2"},
|
|
)
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["code"] == "SNAPSHOT_ALREADY_BOUND"
|
|
|
|
|
|
def test_snapshot_bind_unknown_and_cross_thread_are_404(active_client):
|
|
snapshot_id = _create_snapshot(active_client)
|
|
missing = active_client.post(
|
|
"/api/runtime-snapshots/snap-nope/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
assert missing.status_code == 404
|
|
assert missing.json()["code"] == "SNAPSHOT_NOT_FOUND"
|
|
|
|
cross_thread = active_client.post(
|
|
f"/api/runtime-snapshots/{snapshot_id}/bind",
|
|
headers=_run_headers("thread-2"),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
assert cross_thread.status_code == 404
|
|
assert cross_thread.json()["code"] == "SNAPSHOT_NOT_FOUND"
|
|
|
|
|
|
def test_snapshot_delete_state_machine(active_client):
|
|
snapshot_id = _create_snapshot(active_client)
|
|
deleted = active_client.delete(
|
|
f"/api/runtime-snapshots/{snapshot_id}", headers=_run_headers()
|
|
)
|
|
assert deleted.status_code == 204
|
|
assert deleted.content == b""
|
|
|
|
bound_id = _create_snapshot(active_client, run_request_id="req-2")
|
|
active_client.post(
|
|
f"/api/runtime-snapshots/{bound_id}/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
delete_bound = active_client.delete(
|
|
f"/api/runtime-snapshots/{bound_id}", headers=_run_headers()
|
|
)
|
|
assert delete_bound.status_code == 409
|
|
assert delete_bound.json()["code"] == "SNAPSHOT_ALREADY_BOUND"
|
|
|
|
|
|
def test_snapshot_expired_rejects_bind_and_delete(active_client, services):
|
|
snapshot_id = _create_snapshot(active_client)
|
|
row = services.store.get_run_snapshot(snapshot_id)
|
|
services.store.insert_run_snapshot(
|
|
snapshot_id="snap-expired",
|
|
deployment_id="webui-1",
|
|
thread_id="thread-1",
|
|
run_request_id="req-expired",
|
|
selection_hash=row["selection_hash"],
|
|
payload=row["payload"],
|
|
expires_at=int(time.time()) - 1,
|
|
)
|
|
services.snapshot_service.cleanup_expired()
|
|
|
|
bind = active_client.post(
|
|
"/api/runtime-snapshots/snap-expired/bind",
|
|
headers=_run_headers(),
|
|
json={"langgraph_run_id": "run-1"},
|
|
)
|
|
assert bind.status_code == 409
|
|
assert bind.json()["code"] == "SNAPSHOT_EXPIRED"
|
|
|
|
delete = active_client.delete(
|
|
"/api/runtime-snapshots/snap-expired", headers=_run_headers()
|
|
)
|
|
assert delete.status_code == 409
|
|
assert delete.json()["code"] == "SNAPSHOT_EXPIRED"
|
|
|
|
# The expired triplet is free again: recreation returns 201.
|
|
recreated = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers(),
|
|
json=_snapshot_body(run_request_id="req-expired"),
|
|
)
|
|
assert recreated.status_code == 201
|
|
|
|
|
|
def test_snapshot_error_payload_structure(active_client):
|
|
response = active_client.post(
|
|
"/api/runtime-snapshots",
|
|
headers=_run_headers(),
|
|
json=_snapshot_body(primary={"provider_id": "zhipu-glm", "model_key": "nope"}),
|
|
)
|
|
body = response.json()
|
|
assert set(body) == {"code", "message", "details", "request_id"}
|
|
assert isinstance(body["details"], list)
|
|
assert body["request_id"]
|
|
|
|
|
|
# --- platform configuration ------------------------------------------------
|
|
|
|
|
|
def test_missing_platform_config_refuses_service():
|
|
def _unconfigured():
|
|
raise PlatformConfigError("missing bff_service_token")
|
|
|
|
app = Starlette(routes=model_registry_routes(_unconfigured))
|
|
response = TestClient(app).get("/api/model-registry")
|
|
assert response.status_code == 500
|
|
body = response.json()
|
|
assert body["code"] == "PLATFORM_CONFIG_MISSING"
|
|
assert set(body) == {"code", "message", "details", "request_id"}
|
|
|
|
|
|
# --- OpenAPI export -----------------------------------------------------------------
|
|
|
|
|
|
def test_openapi_export_file_matches_pydantic_schemas():
|
|
assert OPENAPI_PATH.exists()
|
|
exported = json.loads(OPENAPI_PATH.read_text(encoding="utf-8"))
|
|
# The checked-in file is exactly what the export script regenerates.
|
|
assert exported == build_openapi_document()
|
|
|
|
for path in (
|
|
"/api/model-registry",
|
|
"/api/model-registry/credentials/{credential_id}",
|
|
"/api/models",
|
|
"/api/runtime-snapshots",
|
|
"/api/runtime-snapshots/{snapshot_id}/bind",
|
|
"/api/runtime-snapshots/{snapshot_id}",
|
|
):
|
|
assert path in exported["paths"]
|
|
|
|
schema = exported["components"]["schemas"]["GetModelRegistryResponse"]
|
|
assert set(schema["properties"]) == {
|
|
"revision",
|
|
"registry",
|
|
"adapter_specs",
|
|
"credential_status",
|
|
"model_status",
|
|
"endpoint_policy",
|
|
}
|
|
registry_schema = exported["components"]["schemas"]["RegistryV4"]
|
|
assert "providers" in registry_schema["properties"]
|
|
error_schema = exported["components"]["schemas"]["ErrorPayload"]
|
|
assert set(error_schema["properties"]) == {
|
|
"code",
|
|
"message",
|
|
"details",
|
|
"request_id",
|
|
}
|