Files
EvoScientist/tests/test_model_registry_schemas.py
T
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

630 lines
22 KiB
Python

"""Tests for the RegistryV4 schema, adapter contracts, and error taxonomy.
These cover design doc sections 4.3 (configuration objects), 6.2 (adapter
parameter contracts), 6.4 (resolved config), 9.1 (read models), and 9.5
(unified error structure).
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from EvoScientist.model_registry.errors import (
CREDENTIAL_NOT_CONFIGURED,
DELEGATION_REPLAYED,
ERROR_HTTP_STATUS,
MODEL_NOT_FOUND,
PROVIDER_UNREACHABLE,
REGISTRY_REVISION_CONFLICT,
ErrorDetail,
ErrorPayload,
ModelRegistryError,
)
from EvoScientist.model_registry.hashing import configuration_hash
from EvoScientist.model_registry.schemas import (
AdapterParameterSpec,
AuthConfig,
AuthRef,
AuthSpec,
CredentialStatus,
CredentialWrite,
ModelAvailability,
ModelConfig,
ModelRef,
ModelRuntimeConfig,
ParameterRule,
ProviderConfig,
ProviderRuntimeConfig,
RegistryV4,
ResolvedModelConfig,
VerificationInfo,
)
ALL_ERROR_CODES = {
"INVALID_REQUEST": 400,
"REGISTRY_REVISION_CONFLICT": 409,
"THREAD_MODEL_SELECTION_CONFLICT": 409,
"RUN_REQUEST_CONFLICT": 409,
"SNAPSHOT_ALREADY_BOUND": 409,
"SNAPSHOT_EXPIRED": 409,
"MODEL_CONFIGURATION_CHANGED": 409,
"DELEGATION_REPLAYED": 401,
"UNAUTHENTICATED": 401,
"FORBIDDEN": 403,
"MODEL_NOT_FOUND": 404,
"SNAPSHOT_NOT_FOUND": 404,
"MODEL_REGISTRY_NOT_READY": 422,
"MODEL_DISABLED": 422,
"MODEL_NOT_AVAILABLE": 422,
"MODEL_CONFIG_OUTSIDE_SNAPSHOT": 422,
"CREDENTIAL_NOT_CONFIGURED": 422,
"CREDENTIAL_REJECTED": 422,
"RUN_CREDENTIAL_REVISION_UNAVAILABLE": 422,
"AUTH_MODE_UNSUPPORTED": 422,
"ADAPTER_NOT_SUPPORTED": 422,
"ENDPOINT_NOT_ALLOWED": 422,
"CAPABILITY_UNSUPPORTED_BY_ADAPTER": 422,
"MODEL_CAPABILITY_UNAVAILABLE": 422,
"UNSUPPORTED_RUNTIME_PARAMETER": 422,
"MODEL_LIMITS_UNCONFIRMED": 422,
"CONTEXT_BUDGET_UNSATISFIABLE": 422,
"PROVIDER_UNREACHABLE": 422,
"VALIDATION_FAILED": 422,
"PLATFORM_CONFIG_MISSING": 500,
}
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 _provider(**overrides):
payload = {
"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(),
}
],
}
payload.update(overrides)
return payload
def _registry(**overrides):
payload = {
"version": 4,
"revision": 1,
"state": "bootstrap",
"defaults": {
"primary": {"provider_id": "zhipu-glm", "model_key": "glm-5.2"},
},
"providers": [_provider()],
}
payload.update(overrides)
return payload
class TestRegistryV4:
def test_design_doc_example_validates(self):
registry = RegistryV4.model_validate(_registry(state="active", revision=17))
assert registry.version == 4
assert registry.revision == 17
assert registry.state == "active"
assert registry.defaults.primary == ModelRef(
provider_id="zhipu-glm", model_key="glm-5.2"
)
provider = registry.providers[0]
assert provider.runtime.timeout_seconds == 120
model = provider.models[0]
assert model.runtime.min_effective_input_tokens == 8192
assert model.runtime.declared_capabilities.tools is True
def test_version_must_be_four(self):
with pytest.raises(ValidationError):
RegistryV4.model_validate(_registry(version=3))
def test_revision_must_be_positive(self):
with pytest.raises(ValidationError):
RegistryV4.model_validate(_registry(revision=0))
def test_bootstrap_allows_empty_providers_and_null_primary(self):
registry = RegistryV4.model_validate(
_registry(
state="bootstrap",
defaults={"primary": None},
providers=[],
)
)
assert registry.state == "bootstrap"
assert registry.providers == []
def test_active_requires_primary(self):
with pytest.raises(ValidationError):
RegistryV4.model_validate(
_registry(
state="active",
defaults={"primary": None},
)
)
def test_active_requires_primary_referencing_enabled_model(self):
provider = _provider()
provider["models"][0]["enabled"] = False
with pytest.raises(ValidationError):
RegistryV4.model_validate(_registry(state="active", providers=[provider]))
def test_active_rejects_primary_referencing_disabled_provider(self):
provider = _provider(enabled=False)
with pytest.raises(ValidationError):
RegistryV4.model_validate(_registry(state="active", providers=[provider]))
def test_defaults_must_reference_existing_models(self):
with pytest.raises(ValidationError):
RegistryV4.model_validate(
_registry(
defaults={
"primary": {"provider_id": "zhipu-glm", "model_key": "missing"},
}
)
)
def test_provider_ids_must_be_unique(self):
with pytest.raises(ValidationError):
RegistryV4.model_validate(
_registry(providers=[_provider(), _provider(name="Duplicate")])
)
def test_model_keys_must_be_unique_per_provider(self):
provider = _provider()
provider["models"].append(dict(provider["models"][0]))
with pytest.raises(ValidationError):
ProviderConfig.model_validate(provider)
class TestIdPatterns:
@pytest.mark.parametrize(
"value",
["Zhipu", "-abc", ".abc", "_abc", "", "a" * 65, "abc$", "abc def", "ABC"],
)
def test_provider_id_rejects_invalid_patterns(self, value):
with pytest.raises(ValidationError):
ModelRef(provider_id=value, model_key="glm-5.2")
@pytest.mark.parametrize(
"value", ["zhipu-glm", "a", "a.b_c-d", "glm-5.2", "0abc", "x" * 64]
)
def test_provider_id_accepts_valid_patterns(self, value):
ref = ModelRef(provider_id=value, model_key="glm-5.2")
assert ref.provider_id == value
def test_credential_id_uses_same_pattern(self):
with pytest.raises(ValidationError):
AuthConfig(mode="api_key", credential_id="Invalid Id")
def test_upstream_model_id_preserves_case_and_bounds_length(self):
model = ModelConfig.model_validate(
{
"key": "glm-5.2",
"name": "GLM-5.2",
"upstream_model_id": "GLM-5.2-Air.X",
"enabled": True,
"runtime": _model_runtime(),
}
)
assert model.upstream_model_id == "GLM-5.2-Air.X"
with pytest.raises(ValidationError):
ModelConfig.model_validate(
{
"key": "glm-5.2",
"name": "GLM-5.2",
"upstream_model_id": "m" * 301,
"enabled": True,
"runtime": _model_runtime(),
}
)
with pytest.raises(ValidationError):
ModelConfig.model_validate(
{
"key": "glm-5.2",
"name": "GLM-5.2",
"upstream_model_id": "",
"enabled": True,
"runtime": _model_runtime(),
}
)
class TestRuntimeRanges:
@pytest.mark.parametrize("value", [9, 601, 0, -10])
def test_timeout_seconds_range(self, value):
with pytest.raises(ValidationError):
ProviderRuntimeConfig(timeout_seconds=value)
@pytest.mark.parametrize("value", [10, 120, 600])
def test_timeout_seconds_accepts_bounds(self, value):
assert ProviderRuntimeConfig(timeout_seconds=value).timeout_seconds == value
@pytest.mark.parametrize("value", [-1, 6])
def test_max_retries_range(self, value):
with pytest.raises(ValidationError):
ProviderRuntimeConfig(max_retries=value)
def test_temperature_upper_bound_is_two(self):
assert ProviderRuntimeConfig(default_temperature=2).default_temperature == 2
with pytest.raises(ValidationError):
ProviderRuntimeConfig(default_temperature=2.5)
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(_model_runtime(temperature=2.5))
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(_model_runtime(temperature=-0.1))
def test_top_p_is_open_closed_interval(self):
with pytest.raises(ValidationError):
ProviderRuntimeConfig(default_top_p=0)
with pytest.raises(ValidationError):
ProviderRuntimeConfig(default_top_p=1.5)
assert ProviderRuntimeConfig(default_top_p=1).default_top_p == 1
def test_reasoning_effort_defaults_to_auto(self):
assert ProviderRuntimeConfig().default_reasoning_effort == "auto"
runtime = ModelRuntimeConfig.model_validate(_model_runtime())
assert runtime.reasoning_effort == "auto"
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(_model_runtime(reasoning_effort="max"))
def test_min_effective_input_tokens_default_and_floor(self):
runtime = ModelRuntimeConfig.model_validate(
_model_runtime(min_effective_input_tokens=4096)
)
assert runtime.min_effective_input_tokens == 4096
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(
_model_runtime(min_effective_input_tokens=1023)
)
def test_fixed_reserves_are_non_negative(self):
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(
_model_runtime(fixed_system_reserve_tokens=-1)
)
class TestLimitModes:
def test_combined_requires_context_window(self):
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(
_model_runtime(limit_mode="combined", context_window_tokens=None)
)
def test_input_only_requires_max_input_tokens(self):
with pytest.raises(ValidationError):
ModelRuntimeConfig.model_validate(
_model_runtime(limit_mode="input_only", max_input_tokens=None)
)
def test_input_only_valid(self):
runtime = ModelRuntimeConfig.model_validate(
_model_runtime(
limit_mode="input_only",
context_window_tokens=None,
max_input_tokens=131072,
)
)
assert runtime.max_input_tokens == 131072
class TestAuthConfig:
def test_mode_none_forbids_credential_id(self):
with pytest.raises(ValidationError):
AuthConfig(mode="none", credential_id="zhipu-primary")
def test_mode_none_allows_null_credential(self):
auth = AuthConfig(mode="none", credential_id=None)
assert auth.credential_id is None
def test_api_key_allows_credential_reference(self):
auth = AuthConfig(mode="api_key", credential_id="zhipu-primary")
assert auth.mode == "api_key"
def test_unknown_mode_rejected(self):
with pytest.raises(ValidationError):
AuthConfig(mode="oauth")
class TestAdapterContracts:
def _glm_spec_payload(self):
return {
"adapter_id": "openai-compatible",
"spec_revision": 1,
"model_selector": "glm-5.2",
"connection": {
"chat_model": "ChatOpenAI",
"model_field": "model",
"base_url_field": "base_url",
},
"auth_specs": {
"api_key": {
"credential_required": True,
"credential_kind": "api_key",
"target": "client_option",
"target_name": "api_key",
}
},
"parameters": {
"timeout_seconds": {
"supported": True,
"value_type": "integer",
"minimum": 10,
"maximum": 600,
"nullable": "forbidden",
"target": "client_option",
"target_name": "timeout",
"normalizer": "identity",
},
"temperature": {
"supported": True,
"value_type": "number",
"minimum": 0,
"maximum": 1,
"nullable": "omit",
"target": "request_option",
"target_name": "temperature",
"normalizer": "omit_when_none",
},
"reasoning_effort": {
"supported": False,
"value_type": "enum",
"nullable": "omit",
"target": "request_option",
"target_name": "",
"normalizer": "reject_non_auto",
},
},
"protocol_capabilities": {
"tools": True,
"vision": False,
"structured_output": True,
},
}
def test_adapter_parameter_spec_from_design_doc(self):
spec = AdapterParameterSpec.model_validate(self._glm_spec_payload())
assert spec.adapter_id == "openai-compatible"
assert spec.spec_revision == 1
assert spec.model_selector == "glm-5.2"
assert spec.auth_specs["api_key"].credential_kind == "api_key"
assert spec.parameters["temperature"].maximum == 1
assert spec.parameters["reasoning_effort"].supported is False
assert spec.protocol_capabilities.structured_output is True
def test_auth_spec_rejects_unknown_credential_kind(self):
with pytest.raises(ValidationError):
AuthSpec(
credential_required=True,
credential_kind="oauth_token",
target="client_option",
target_name="api_key",
)
def test_parameter_rule_conflicts_default_empty(self):
rule = ParameterRule(
supported=True,
value_type="integer",
nullable="forbidden",
target="client_option",
target_name="max_retries",
normalizer="identity",
)
assert rule.conflicts_with == []
class TestAvailabilityAndResolvedConfig:
def test_model_availability_shape(self):
availability = ModelAvailability(
model_ref=ModelRef(provider_id="zhipu-glm", model_key="glm-5.2"),
state="enabled",
selectable=True,
reason_code=None,
verification=VerificationInfo(status="passed"),
effective_capabilities={
"tools": True,
"vision": False,
"structured_output": True,
},
)
assert availability.selectable is True
assert availability.verification.status == "passed"
def test_resolved_model_config_has_no_secret_value(self):
resolved = ResolvedModelConfig(
model_ref=ModelRef(provider_id="zhipu-glm", model_key="glm-5.2"),
role="primary",
adapter_id="openai-compatible",
adapter_spec_revision=1,
upstream_model_id="glm-5.2",
base_url="https://open.bigmodel.cn/api/paas/v4",
auth_ref=AuthRef(
mode="api_key", credential_id="zhipu-primary", credential_revision=3
),
client_options={"timeout_seconds": 120, "max_retries": 2},
request_options={
"max_output_tokens": 32768,
"temperature": 0.7,
"top_p": 0.95,
"reasoning_effort": "auto",
},
budget={
"resolved_input_limit": 1048576 - 32768,
"fixed_reserves": {
"fixed_system_reserve_tokens": 4096,
"fixed_tools_reserve_tokens": 8192,
"fixed_attachments_reserve_tokens": 4096,
},
"message_budget": 1048576 - 32768 - 16384,
},
effective_capabilities={
"tools": True,
"vision": False,
"structured_output": True,
},
)
assert "secret" not in ResolvedModelConfig.model_fields
assert "secret_value" not in AuthRef.model_fields
assert resolved.auth_ref.credential_revision == 3
def test_auth_ref_mode_none_forbids_credential(self):
with pytest.raises(ValidationError):
AuthRef(mode="none", credential_id="zhipu-primary")
class TestCredentialModels:
def test_credential_status_shape(self):
status = CredentialStatus(
credential_id="zhipu-primary",
configured=True,
hint="...abcd",
updated_at="2026-07-20T00:00:00Z",
)
assert status.hint == "...abcd"
def test_credential_write_requires_replace_operation(self):
write = CredentialWrite(
credential_id="zhipu-primary", secret_value="sk-test-1234"
)
assert write.operation == "replace"
with pytest.raises(ValidationError):
CredentialWrite(
credential_id="zhipu-primary",
operation="append",
secret_value="sk-test-1234",
)
class TestErrorTaxonomy:
def test_error_table_covers_all_documented_codes(self):
assert ERROR_HTTP_STATUS == ALL_ERROR_CODES
def test_spot_check_http_mappings(self):
assert ERROR_HTTP_STATUS[REGISTRY_REVISION_CONFLICT] == 409
assert ERROR_HTTP_STATUS[DELEGATION_REPLAYED] == 401
assert ERROR_HTTP_STATUS[MODEL_NOT_FOUND] == 404
assert ERROR_HTTP_STATUS[PROVIDER_UNREACHABLE] == 422
def test_error_payload_matches_section_9_5(self):
payload = ErrorPayload(
code="STABLE_ERROR_CODE",
message="safe human-readable message",
details=[
ErrorDetail(
path="providers[0].runtime.temperature",
code="UNSUPPORTED_RUNTIME_PARAMETER",
)
],
request_id="req-1",
)
assert payload.model_dump() == {
"code": "STABLE_ERROR_CODE",
"message": "safe human-readable message",
"details": [
{
"path": "providers[0].runtime.temperature",
"code": "UNSUPPORTED_RUNTIME_PARAMETER",
}
],
"request_id": "req-1",
}
def test_registry_error_carries_code_status_and_payload(self):
error = ModelRegistryError(
CREDENTIAL_NOT_CONFIGURED,
"credential is not configured",
details=[{"path": "providers[0].auth", "code": CREDENTIAL_NOT_CONFIGURED}],
)
assert error.code == CREDENTIAL_NOT_CONFIGURED
assert error.http_status == 422
payload = error.payload(request_id="req-9")
assert payload["code"] == CREDENTIAL_NOT_CONFIGURED
assert payload["request_id"] == "req-9"
assert payload["details"] == [
{"path": "providers[0].auth", "code": CREDENTIAL_NOT_CONFIGURED}
]
def test_unknown_error_code_rejected(self):
with pytest.raises(ValueError, match="Unknown model registry error code"):
ModelRegistryError("NOT_A_REAL_CODE", "bad")
class TestConfigurationHash:
def test_hash_is_deterministic(self):
provider = ProviderConfig.model_validate(_provider())
first = configuration_hash(provider, provider.models[0])
second = configuration_hash(provider, provider.models[0])
assert first == second
assert len(first) == 64
def test_hash_changes_with_runtime_parameters(self):
provider = ProviderConfig.model_validate(_provider())
changed = ProviderConfig.model_validate(
_provider(runtime={"timeout_seconds": 300})
)
assert configuration_hash(provider, provider.models[0]) != configuration_hash(
changed, changed.models[0]
)
def test_hash_changes_with_base_url_and_upstream_model(self):
provider = ProviderConfig.model_validate(_provider())
other_url = ProviderConfig.model_validate(
_provider(base_url="https://example.com/v1")
)
assert configuration_hash(provider, provider.models[0]) != configuration_hash(
other_url, other_url.models[0]
)
def test_hash_changes_with_limits(self):
provider = ProviderConfig.model_validate(_provider())
changed_limits = _provider()
changed_limits["models"][0]["runtime"] = _model_runtime(max_output_tokens=16384)
other = ProviderConfig.model_validate(changed_limits)
assert configuration_hash(provider, provider.models[0]) != configuration_hash(
other, other.models[0]
)