Files
EvoScientist/tests/test_provider_test_api.py
m4 bb9bed82e1 feat(runtime)!: remove auxiliary model role, resolve all roles from run snapshot
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>
2026-07-23 10:18:44 +08:00

601 lines
19 KiB
Python

"""Tests for POST /api/model-registry/test (design doc 9.4, 4.3, 5.1, 6.2).
A Task 2 style fake DNS plus a local threaded HTTP server (registered as a
``development_endpoints`` entry) simulates an OpenAI-compatible provider:
normal chat completions, 401, 404, connection refusal, and tool-call
requests. Covers the full verification flow — frozen resolution, credential
resolution, the minimal real call, capability probes, the guarded
``model_verifications`` upsert, response redaction, and the
``model_config:test`` scope — plus every section 9.4 error path.
"""
from __future__ import annotations
import json
import socket
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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.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,
model_registry_routes,
)
from EvoScientist.model_registry.platform import DelegationPublicKey
from EvoScientist.model_registry.provider_test import ProviderTester
from EvoScientist.model_registry.resolver import ModelRegistryResolver
from EvoScientist.model_registry.safe_transport import (
build_safe_async_http_client,
build_safe_http_client,
)
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"
MODEL_REF = {"provider_id": "test-provider", "model_key": "test-model"}
_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,
)
# --- fake OpenAI-compatible provider -----------------------------------------
def _completion(content):
return {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
class _OpenAIHandler(BaseHTTPRequestHandler):
"""OpenAI-compatible chat completions with a switchable failure mode."""
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
self.server.requests.append(
{
"path": self.path,
"authorization": self.headers.get("Authorization"),
"body": body,
}
)
mode = self.server.mode
if mode == "reject" or (mode == "fail_tools" and body.get("tools")):
status = 401 if mode == "reject" else 400
self._respond(
status, {"error": {"message": "rejected", "type": "auth_error"}}
)
return
if mode == "missing_model":
self._respond(
404,
{"error": {"message": "model not found", "type": "not_found"}},
)
return
# The structured-output probe sends response_format; answer with JSON.
content = json.dumps({"answer": "ok"}) if body.get("response_format") else "ok"
self._respond(200, _completion(content))
def _respond(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def openai_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), _OpenAIHandler)
server.requests = []
server.mode = "ok"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield server
server.shutdown()
server.server_close()
thread.join()
@pytest.fixture
def closed_port():
"""A loopback port with no listener (connection refused)."""
probe = socket.socket()
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
probe.close()
return port
def _fake_getaddrinfo(counter):
def fake(host, port, *args, **kwargs):
counter["calls"] += 1
assert host == "localhost"
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))]
return fake
# --- registry fixtures ---------------------------------------------------------
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": True,
"structured_output": True,
},
}
payload.update(overrides)
return payload
def _provider_payload(port, **overrides):
provider = {
"id": "test-provider",
"name": "Test Provider",
"adapter": "openai-compatible",
"base_url": f"http://localhost:{port}/v1",
"auth": {"mode": "api_key", "credential_id": "test-key"},
"enabled": True,
"runtime": {
"timeout_seconds": 120,
"max_retries": 2,
"default_temperature": 0.7,
"default_top_p": 0.95,
"default_reasoning_effort": "auto",
},
"models": [
{
"key": "test-model",
"name": "Test Model",
"upstream_model_id": "test-model",
"enabled": False,
"runtime": _model_runtime(),
}
],
}
provider.update(overrides)
return provider
def _registry_payload(port, **provider_overrides):
return {
"version": 4,
"revision": 1,
"state": "bootstrap",
"defaults": {"primary": None},
"providers": [_provider_payload(port, **provider_overrides)],
}
def _save(store, port, *, credential=True, **provider_overrides):
writes = (
[CredentialWrite(credential_id="test-key", secret_value=SECRET)]
if credential
else []
)
return store.save_registry(
expected_revision=1,
registry=RegistryV4.model_validate(
_registry_payload(port, **provider_overrides)
),
credential_writes=writes,
)
# --- app fixtures --------------------------------------------------------------
@pytest.fixture
def store(tmp_path):
return ModelRuntimeStore(config_dir=tmp_path)
def _policy_for(port):
return EndpointPolicy(
[
DevelopmentEndpoint(
id="test-provider",
url=f"http://localhost:{port}/v1",
label="Test provider",
)
]
)
def _services_for(store, port):
resolver = ModelRegistryResolver(store)
policy = _policy_for(port)
fake_dns = _fake_getaddrinfo({"calls": 0})
tester = ProviderTester(
store,
resolver,
policy,
sync_client_builder=lambda timeout, retries: build_safe_http_client(
policy, timeout=timeout, retries=retries, getaddrinfo=fake_dns
),
async_client_builder=lambda timeout, retries: build_safe_async_http_client(
policy, timeout=timeout, retries=retries, getaddrinfo=fake_dns
),
)
return ApiServices(
store=store,
resolver=resolver,
snapshot_service=SnapshotService(store, resolver),
endpoint_policy=policy,
authenticator=BffAuthenticator(
service_token=SERVICE_TOKEN,
service_token_hash=None,
delegation_keys=(
DelegationPublicKey(deployment_id="webui-1", public_key=_PUBLIC_PEM),
),
jti_store=store,
),
provider_tester=tester,
)
@pytest.fixture
def services(store, openai_server):
return _services_for(store, openai_server.server_address[1])
def _client_for(services):
app = Starlette(routes=model_registry_routes(lambda: services))
return TestClient(app)
@pytest.fixture
def client(services):
return _client_for(services)
@pytest.fixture
def saved_store(store, openai_server):
_save(store, openai_server.server_address[1])
return store
@pytest.fixture
def test_client(client, saved_store):
return client
# --- auth helpers ---------------------------------------------------------------
def _headers(scopes):
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,
}
return {
"Authorization": f"Bearer {SERVICE_TOKEN}",
"X-Evo-Actor": jwt.encode(claims, _PRIVATE_PEM, algorithm="ES256"),
}
def _test_headers():
return _headers(["model_config:test"])
def _post(client, *, model_ref=None, expected_revision=2, headers=None):
return client.post(
"/api/model-registry/test",
headers=headers or _test_headers(),
json={
"expected_registry_revision": expected_revision,
"model_ref": model_ref or MODEL_REF,
},
)
# --- success paths ---------------------------------------------------------------
def test_success_full_flow(test_client, saved_store, openai_server):
response = _post(test_client)
assert response.status_code == 200
body = response.json()
assert set(body) == {
"ok",
"registry_revision",
"model_ref",
"adapter_spec_revision",
"effective_request_options",
"model_status",
"latency_ms",
}
assert body["ok"] is True
assert body["registry_revision"] == 2
assert body["model_ref"] == MODEL_REF
assert body["adapter_spec_revision"] == 1
# The redacted build_request output matches the section 9.4 example shape.
assert body["effective_request_options"] == {
"timeout": 120,
"max_retries": 2,
"max_tokens": 32768,
"temperature": 0.7,
"top_p": 0.95,
}
assert isinstance(body["latency_ms"], int)
assert body["latency_ms"] >= 0
# The model was saved disabled: verified, not yet selectable.
assert body["model_status"]["state"] == "verified"
assert body["model_status"]["selectable"] is False
assert body["model_status"]["verification"]["status"] == "passed"
assert body["model_status"]["effective_capabilities"] == {
"tools": True,
"vision": True,
"structured_output": True,
}
# The response never carries the secret (section 5.1).
assert SECRET not in response.text
# The five-tuple record was upserted (section 4.3).
registry = saved_store.load_registry()
provider = registry.find_provider("test-provider")
model = provider.find_model("test-model")
records = saved_store.list_model_verifications()
assert len(records) == 1
record = records[0]
assert record["provider_id"] == "test-provider"
assert record["model_key"] == "test-model"
assert record["configuration_hash"] == configuration_hash(provider, model)
assert record["credential_revision"] == 1
assert record["adapter_spec_revision"] == 1
assert record["result"] == "passed"
assert record["verified_capabilities"] == {
"tools": True,
"vision": True,
"structured_output": True,
}
assert record["error_code"] is None
# The provider saw the minimal chat call plus one request per declared
# capability probe; every request carried the resolved credential.
requests = openai_server.requests
assert len(requests) == 4
assert all(request["path"] == "/v1/chat/completions" for request in requests)
assert all(request["authorization"] == f"Bearer {SECRET}" for request in requests)
assert any("tools" in request["body"] for request in requests)
assert any("response_format" in request["body"] for request in requests)
assert any("image_url" in json.dumps(request["body"]) for request in requests)
def test_enabled_model_reports_enabled_status(client, store, openai_server):
port = openai_server.server_address[1]
provider = _provider_payload(port)
provider["models"][0]["enabled"] = True
_save(store, port, models=provider["models"])
response = _post(client)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["model_status"]["state"] == "enabled"
assert body["model_status"]["selectable"] is True
def test_tools_probe_failure_marks_capability_false(
test_client, saved_store, openai_server
):
openai_server.mode = "fail_tools"
response = _post(test_client)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["model_status"]["effective_capabilities"]["tools"] is False
assert body["model_status"]["effective_capabilities"]["vision"] is True
record = saved_store.list_model_verifications()[0]
assert record["result"] == "passed"
assert record["verified_capabilities"] == {
"tools": False,
"vision": True,
"structured_output": True,
}
# --- precondition and provider errors -------------------------------------------
def test_unsaved_model_ref_is_404(test_client, saved_store):
response = _post(
test_client,
model_ref={"provider_id": "test-provider", "model_key": "free-draft"},
)
assert response.status_code == 404
assert response.json()["code"] == "MODEL_NOT_FOUND"
response = _post(
test_client, model_ref={"provider_id": "draft", "model_key": "test-model"}
)
assert response.status_code == 404
assert response.json()["code"] == "MODEL_NOT_FOUND"
assert saved_store.list_model_verifications() == []
def test_missing_credential_is_422(client, store, openai_server):
_save(store, openai_server.server_address[1], credential=False)
response = _post(client)
assert response.status_code == 422
assert response.json()["code"] == "CREDENTIAL_NOT_CONFIGURED"
assert store.list_model_verifications() == []
def test_credential_rejected_records_failed_result(
test_client, saved_store, openai_server
):
openai_server.mode = "reject"
response = _post(test_client)
assert response.status_code == 422
body = response.json()
assert body["code"] == "CREDENTIAL_REJECTED"
assert SECRET not in response.text
record = saved_store.list_model_verifications()[0]
assert record["result"] == "failed"
assert record["error_code"] == "CREDENTIAL_REJECTED"
assert record["verified_capabilities"] == {
"tools": False,
"vision": False,
"structured_output": False,
}
def test_provider_404_is_model_not_found(test_client, saved_store, openai_server):
openai_server.mode = "missing_model"
response = _post(test_client)
# The section 9.5 table pins MODEL_NOT_FOUND to 404 for every source.
assert response.status_code == 404
assert response.json()["code"] == "MODEL_NOT_FOUND"
record = saved_store.list_model_verifications()[0]
assert record["result"] == "failed"
assert record["error_code"] == "MODEL_NOT_FOUND"
def test_connection_refused_is_provider_unreachable(store, closed_port):
# The policy must register the unreachable port; the failure must come
# from the connection attempt, not the endpoint allowlist.
_save(store, closed_port)
client = _client_for(_services_for(store, closed_port))
response = _post(client)
assert response.status_code == 422
assert response.json()["code"] == "PROVIDER_UNREACHABLE"
record = store.list_model_verifications()[0]
assert record["result"] == "failed"
assert record["error_code"] == "PROVIDER_UNREACHABLE"
def test_unregistered_local_endpoint_is_rejected(client, store):
# Saved directly through the store (no save-time policy hook), so the
# test endpoint must re-validate the base URL before any I/O.
port = 9 # unregistered loopback endpoint
store.save_registry(
expected_revision=1,
registry=RegistryV4.model_validate(_registry_payload(port)),
credential_writes=[
CredentialWrite(credential_id="test-key", secret_value=SECRET)
],
)
response = _post(client)
assert response.status_code == 422
assert response.json()["code"] == "ENDPOINT_NOT_ALLOWED"
assert store.list_model_verifications() == []
# --- verification record transaction --------------------------------------------
def test_concurrent_registry_change_is_409_and_writes_nothing(
test_client, saved_store, services, monkeypatch
):
tester = services.provider_tester
original_execute = tester._execute
def mutate_then_execute(*args, **kwargs):
# A concurrent admin save between resolution and the record write.
registry = saved_store.load_registry()
payload = registry.model_dump(mode="json")
payload["providers"][0]["models"][0]["name"] = "Renamed Mid-Test"
saved_store.save_registry(
expected_revision=registry.revision,
registry=RegistryV4.model_validate(payload),
)
return original_execute(*args, **kwargs)
monkeypatch.setattr(tester, "_execute", mutate_then_execute)
response = _post(test_client)
assert response.status_code == 409
assert response.json()["code"] == "MODEL_CONFIGURATION_CHANGED"
# The stale test result must not be recorded for the new configuration.
assert saved_store.list_model_verifications() == []
def test_stale_expected_revision_is_409(test_client, saved_store):
response = _post(test_client, expected_revision=99)
assert response.status_code == 409
assert response.json()["code"] == "MODEL_CONFIGURATION_CHANGED"
assert saved_store.list_model_verifications() == []
# --- authentication ---------------------------------------------------------------
def test_requires_test_scope(test_client, saved_store):
response = _post(test_client, headers=_headers(["model_config:write"]))
assert response.status_code == 403
assert response.json()["code"] == "FORBIDDEN"
def test_requires_authentication(test_client, saved_store):
response = test_client.post(
"/api/model-registry/test",
json={"expected_registry_revision": 2, "model_ref": MODEL_REF},
)
assert response.status_code == 401