Files
EvoScientist/tests/test_adapter_contracts.py
m4 b2e28249fd fix(model-registry): inject async safe clients and split ollama transports
Review fixes for the Task 3 contract layer:

- build_chat_model now accepts http_async_client alongside http_client
  (at least one required) and wires it into ChatOpenAI
  (http_async_client), ChatAnthropic (seeded _async_client), and
  ChatOllama (async_client_kwargs transport), closing the unsafe
  default-async-client gap.
- ChatOllama safe transports move from the shared client_kwargs to
  sync_client_kwargs/async_client_kwargs; langchain-ollama merges shared
  kwargs into both clients, which poisoned the async client with a sync
  transport and crashed ainvoke.
- Unsupported parameters now actually execute the contract-declared
  normalizer (reject_non_auto) instead of a hardcoded raise, with a
  fallback rejection if a normalizer would let a value through.
- build_chat_model rejects overlapping client_options/request_options
  keys instead of silently overwriting.
2026-07-20 22:40:33 +08:00

695 lines
27 KiB
Python

"""Tests for the adapter parameter contracts (design doc sections 6.1-6.3).
Covers the built-in contract registry (five phase-1 adapters plus the
``openai-compatible``/``glm-5.2`` model-specific contract), the contract
matching order, save-time parameter resolution with the section 6.1
inheritance semantics, and ``Adapter.build_request`` mapping.
"""
from __future__ import annotations
import pytest
from EvoScientist.model_registry.adapters import (
Adapter,
adapter_specs,
compute_effective_capabilities,
find_adapter_spec,
get_adapter,
resolve_parameters,
)
from EvoScientist.model_registry.errors import (
ADAPTER_NOT_SUPPORTED,
AUTH_MODE_UNSUPPORTED,
CAPABILITY_UNSUPPORTED_BY_ADAPTER,
CREDENTIAL_NOT_CONFIGURED,
UNSUPPORTED_RUNTIME_PARAMETER,
ModelRegistryError,
)
from EvoScientist.model_registry.schemas import (
AdapterParameterSpec,
Capabilities,
ModelConfig,
ModelRuntimeConfig,
ProviderConfig,
ResolvedModelConfig,
)
PHASE_ONE_ADAPTERS = [
"openai",
"anthropic",
"openai-compatible",
"anthropic-compatible",
"ollama",
]
UNOPENED_ADAPTERS = ["google-genai", "grok", "openrouter", "nvidia", "antigravity"]
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 ProviderConfig.model_validate(payload)
def _model(provider: ProviderConfig, **runtime_overrides) -> ModelConfig:
model = provider.models[0]
if not runtime_overrides:
return model
runtime = model.runtime.model_dump()
runtime.update(runtime_overrides)
return model.model_copy(
update={"runtime": ModelRuntimeConfig.model_validate(runtime)}
)
def _resolved_config(**overrides):
payload = {
"model_ref": {"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": {
"mode": "api_key",
"credential_id": "zhipu-primary",
"credential_revision": 1,
},
"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": 1015808,
"fixed_reserves": {
"fixed_system_reserve_tokens": 4096,
"fixed_tools_reserve_tokens": 8192,
"fixed_attachments_reserve_tokens": 4096,
},
"message_budget": 1000000,
},
"effective_capabilities": {
"tools": True,
"vision": False,
"structured_output": True,
},
}
payload.update(overrides)
return ResolvedModelConfig.model_validate(payload)
class TestContractRegistry:
@pytest.mark.parametrize("adapter_id", PHASE_ONE_ADAPTERS)
def test_generic_contract_exists_for_each_phase_one_adapter(self, adapter_id):
spec = find_adapter_spec(adapter_id, "any-model")
assert spec is not None
assert spec.adapter_id == adapter_id
assert spec.model_selector == "*"
assert spec.spec_revision == 1
assert spec.connection is not None
assert spec.parameters
def test_glm_contract_matches_design_doc_verbatim(self):
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
assert spec.model_selector == "glm-5.2"
assert spec.spec_revision == 1
assert spec.connection is not None
assert spec.connection.chat_model == "ChatOpenAI"
assert spec.connection.model_field == "model"
assert spec.connection.base_url_field == "base_url"
auth = spec.auth_specs["api_key"]
assert auth.credential_required is True
assert auth.credential_kind == "api_key"
assert auth.target == "client_option"
assert auth.target_name == "api_key"
parameters = spec.parameters
timeout = parameters["timeout_seconds"]
assert (timeout.supported, timeout.value_type) == (True, "integer")
assert (timeout.minimum, timeout.maximum) == (10, 600)
assert timeout.nullable == "forbidden"
assert (timeout.target, timeout.target_name) == ("client_option", "timeout")
assert timeout.normalizer == "identity"
retries = parameters["max_retries"]
assert (retries.minimum, retries.maximum) == (0, 5)
assert (retries.target, retries.target_name) == (
"client_option",
"max_retries",
)
max_output = parameters["max_output_tokens"]
assert (max_output.minimum, max_output.maximum) == (1, 32768)
assert (max_output.target, max_output.target_name) == (
"request_option",
"max_tokens",
)
assert max_output.normalizer == "clamp_to_model_limit"
temperature = parameters["temperature"]
assert (temperature.minimum, temperature.maximum) == (0, 1)
assert temperature.nullable == "omit"
assert temperature.normalizer == "omit_when_none"
top_p = parameters["top_p"]
assert (top_p.minimum, top_p.maximum) == (0.000001, 1)
reasoning = parameters["reasoning_effort"]
assert reasoning.supported is False
assert reasoning.nullable == "omit"
assert reasoning.target_name == ""
assert reasoning.normalizer == "reject_non_auto"
assert spec.protocol_capabilities == Capabilities(
tools=True, vision=False, structured_output=True
)
def test_exact_match_beats_generic_contract(self):
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
assert spec.model_selector == "glm-5.2"
def test_other_models_fall_back_to_generic_contract(self):
spec = find_adapter_spec("openai-compatible", "some-other-model")
assert spec is not None
assert spec.model_selector == "*"
def test_longest_glob_beats_shorter_glob(self):
extra = [
spec.model_copy(update={"model_selector": "glm-*"})
for spec in adapter_specs()
if spec.adapter_id == "openai-compatible"
and spec.model_selector == "glm-5.2"
]
spec = find_adapter_spec(
"openai-compatible", "glm-5.2-flash", specs=[*extra, *adapter_specs()]
)
assert spec is not None
assert spec.model_selector == "glm-*"
@pytest.mark.parametrize("adapter_id", UNOPENED_ADAPTERS)
def test_unopened_adapter_ids_rejected(self, adapter_id):
with pytest.raises(ModelRegistryError) as excinfo:
find_adapter_spec(adapter_id, "any-model")
assert excinfo.value.code == ADAPTER_NOT_SUPPORTED
def test_unknown_adapter_id_rejected(self):
with pytest.raises(ModelRegistryError) as excinfo:
find_adapter_spec("made-up-adapter", "any-model")
assert excinfo.value.code == ADAPTER_NOT_SUPPORTED
def test_pinned_spec_revision_must_exist(self):
assert (
find_adapter_spec("openai-compatible", "glm-5.2", spec_revision=1)
is not None
)
assert (
find_adapter_spec("openai-compatible", "glm-5.2", spec_revision=99) is None
)
with pytest.raises(ModelRegistryError) as excinfo:
get_adapter("openai-compatible", "glm-5.2", spec_revision=99)
assert excinfo.value.code == ADAPTER_NOT_SUPPORTED
class TestGlm52Resolution:
def test_design_doc_mapping(self):
provider = _provider()
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
resolved = resolve_parameters(provider, provider.models[0], spec)
assert resolved.timeout_seconds == 120
assert resolved.max_retries == 2
assert resolved.max_output_tokens == 32768
assert resolved.temperature == 0.7
assert resolved.top_p == 0.95
assert resolved.reasoning_effort == "auto"
adapter = Adapter(spec)
request = adapter.build_request(_resolved_config(), credential="test-secret")
assert request.client_options == {
"model": "glm-5.2",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"timeout": 120,
"max_retries": 2,
"api_key": "test-secret",
}
assert request.request_options == {
"max_tokens": 32768,
"temperature": 0.7,
"top_p": 0.95,
}
def test_model_value_overrides_provider_default(self):
provider = _provider()
model = _model(provider, temperature=0.4)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
resolved = resolve_parameters(provider, model, spec)
assert resolved.temperature == 0.4
def test_provider_null_omits_field_and_never_sends_zero(self):
provider = _provider()
provider = provider.model_copy(
update={
"runtime": provider.runtime.model_copy(
update={"default_temperature": None, "default_top_p": None}
)
}
)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
resolved = resolve_parameters(provider, provider.models[0], spec)
assert resolved.temperature is None
assert resolved.top_p is None
adapter = Adapter(spec)
request = adapter.build_request(
_resolved_config(
request_options={
"max_output_tokens": 32768,
"temperature": None,
"top_p": None,
"reasoning_effort": "auto",
}
),
credential="test-secret",
)
assert "temperature" not in request.request_options
assert "top_p" not in request.request_options
assert request.request_options.get("temperature") != 0
def test_reasoning_effort_auto_is_omitted(self):
adapter = get_adapter("openai-compatible", "glm-5.2")
request = adapter.build_request(_resolved_config(), credential="test-secret")
assert all("reasoning" not in key for key in request.request_options)
def test_reasoning_effort_non_auto_rejected_at_save(self):
provider = _provider()
model = _model(provider, reasoning_effort="high")
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_inherited_reasoning_effort_non_auto_rejected(self):
provider = _provider()
provider = provider.model_copy(
update={
"runtime": provider.runtime.model_copy(
update={"default_reasoning_effort": "high"}
)
}
)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, provider.models[0], spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_reasoning_effort_non_auto_rejected_at_run(self):
adapter = get_adapter("openai-compatible", "glm-5.2")
resolved = _resolved_config(
request_options={
"max_output_tokens": 32768,
"temperature": 0.7,
"top_p": 0.95,
"reasoning_effort": "high",
}
)
with pytest.raises(ModelRegistryError) as excinfo:
adapter.build_request(resolved, credential="test-secret")
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_temperature_above_contract_maximum_rejected(self):
provider = _provider()
model = _model(provider, temperature=1.5)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_top_p_below_contract_minimum_rejected(self):
provider = _provider()
model = _model(provider, top_p=0.0000001)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_max_output_tokens_clamped_to_model_limit(self):
provider = _provider()
model = _model(provider, max_output_tokens=40000)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
resolved = resolve_parameters(provider, model, spec)
assert resolved.max_output_tokens == 32768
def test_declared_vision_capability_rejected(self):
provider = _provider()
model = _model(
provider,
declared_capabilities={
"tools": True,
"vision": True,
"structured_output": True,
},
)
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == CAPABILITY_UNSUPPORTED_BY_ADAPTER
def test_auth_mode_outside_contract_rejected(self):
provider = _provider(auth={"mode": "bearer", "credential_id": "zhipu-primary"})
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, provider.models[0], spec)
assert excinfo.value.code == AUTH_MODE_UNSUPPORTED
def test_missing_credential_reference_rejected(self):
provider = _provider(auth={"mode": "api_key", "credential_id": None})
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, provider.models[0], spec)
assert excinfo.value.code == CREDENTIAL_NOT_CONFIGURED
def test_build_request_requires_credential(self):
adapter = get_adapter("openai-compatible", "glm-5.2")
with pytest.raises(ModelRegistryError) as excinfo:
adapter.build_request(_resolved_config())
assert excinfo.value.code == CREDENTIAL_NOT_CONFIGURED
def test_build_request_rejects_unsupported_auth_mode(self):
adapter = get_adapter("openai-compatible", "glm-5.2")
resolved = _resolved_config(
auth_ref={
"mode": "bearer",
"credential_id": "zhipu-primary",
"credential_revision": 1,
}
)
with pytest.raises(ModelRegistryError) as excinfo:
adapter.build_request(resolved, credential="test-secret")
assert excinfo.value.code == AUTH_MODE_UNSUPPORTED
def _anthropic_models():
"""Anthropic protocol declares no structured_output in phase 1."""
return [
{
"key": "glm-5.2",
"name": "GLM-5.2",
"upstream_model_id": "glm-5.2",
"enabled": True,
"runtime": _model_runtime(
declared_capabilities={
"tools": True,
"vision": False,
"structured_output": False,
}
),
}
]
class TestGenericContractMappings:
def _resolve_and_build(self, provider: ProviderConfig, credential=None):
model = provider.models[0]
spec = find_adapter_spec(provider.adapter, model.upstream_model_id)
assert spec is not None
resolved = resolve_parameters(provider, model, spec)
config = _resolved_config(
model_ref={"provider_id": provider.id, "model_key": model.key},
adapter_id=provider.adapter,
adapter_spec_revision=spec.spec_revision,
upstream_model_id=model.upstream_model_id,
base_url=provider.base_url,
auth_ref={
"mode": provider.auth.mode,
"credential_id": provider.auth.credential_id,
"credential_revision": (
1 if provider.auth.credential_id is not None else None
),
},
client_options={
"timeout_seconds": resolved.timeout_seconds,
"max_retries": resolved.max_retries,
},
request_options={
"max_output_tokens": resolved.max_output_tokens,
"temperature": resolved.temperature,
"top_p": resolved.top_p,
"reasoning_effort": resolved.reasoning_effort,
},
)
return spec, Adapter(spec).build_request(config, credential=credential)
def test_openai_generic_contract(self):
provider = _provider(
id="openai-main",
adapter="openai",
base_url="https://api.openai.com/v1",
)
spec, request = self._resolve_and_build(provider, credential="sk-test")
assert spec.connection is not None
assert spec.connection.chat_model == "ChatOpenAI"
assert request.client_options["model"] == "glm-5.2"
assert request.client_options["timeout"] == 120
assert request.client_options["max_retries"] == 2
assert request.client_options["api_key"] == "sk-test"
assert request.request_options["max_tokens"] == 32768
assert request.request_options["temperature"] == 0.7
def test_openai_generic_contract_supports_reasoning_effort(self):
provider = _provider(
id="openai-main",
adapter="openai",
base_url="https://api.openai.com/v1",
)
provider.models[0].runtime.reasoning_effort = "low"
spec = find_adapter_spec("openai", "glm-5.2")
assert spec is not None
resolved = resolve_parameters(provider, provider.models[0], spec)
assert resolved.reasoning_effort == "low"
_, request = self._resolve_and_build(provider, credential="sk-test")
assert request.request_options["reasoning_effort"] == "low"
def test_anthropic_generic_contract(self):
provider = _provider(
id="anthropic-main",
adapter="anthropic",
base_url="https://api.anthropic.com",
auth={"mode": "api_key", "credential_id": "anthropic-primary"},
models=_anthropic_models(),
)
spec, request = self._resolve_and_build(provider, credential="sk-ant")
assert spec.connection is not None
assert spec.connection.chat_model == "ChatAnthropic"
assert request.client_options["max_retries"] == 2
assert request.client_options["api_key"] == "sk-ant"
assert request.request_options["max_tokens"] == 32768
assert "reasoning_effort" not in request.request_options
def test_anthropic_rejects_non_auto_reasoning_effort(self):
provider = _provider(
id="anthropic-main",
adapter="anthropic",
base_url="https://api.anthropic.com",
models=_anthropic_models(),
)
provider.models[0].runtime.reasoning_effort = "medium"
spec = find_adapter_spec("anthropic", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, provider.models[0], spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_openai_compatible_bearer_auth_maps_to_header(self):
provider = _provider(
id="compat",
adapter="openai-compatible",
base_url="https://gateway.example.com/v1",
auth={"mode": "bearer", "credential_id": "compat-token"},
models=[
{
"key": "other",
"name": "Other",
"upstream_model_id": "other-model",
"enabled": True,
"runtime": _model_runtime(),
}
],
)
spec, request = self._resolve_and_build(provider, credential="tok-123")
assert spec.model_selector == "*"
assert request.client_options["default_headers"] == {
"Authorization": "Bearer tok-123"
}
assert "api_key" not in request.client_options
def test_anthropic_compatible_generic_contract(self):
provider = _provider(
id="ant-compat",
adapter="anthropic-compatible",
base_url="https://anthropic-gateway.example.com",
models=_anthropic_models(),
)
spec, request = self._resolve_and_build(provider, credential="sk-ant")
assert spec.connection is not None
assert spec.connection.chat_model == "ChatAnthropic"
assert request.client_options["base_url"] == (
"https://anthropic-gateway.example.com"
)
assert request.request_options["max_tokens"] == 32768
def test_ollama_generic_contract_needs_no_credential(self):
provider = _provider(
id="local-ollama",
adapter="ollama",
base_url="http://localhost:11434",
auth={"mode": "none", "credential_id": None},
)
spec, request = self._resolve_and_build(provider)
assert spec.connection is not None
assert spec.connection.chat_model == "ChatOllama"
assert request.client_options["model"] == "glm-5.2"
assert request.client_options["base_url"] == "http://localhost:11434"
assert request.client_options["client_kwargs"] == {"timeout": 120}
assert request.request_options["num_predict"] == 32768
assert not any(
"key" in option or "authorization" in option.lower()
for option in request.client_options
)
def test_ollama_mode_none_discards_credential(self):
provider = _provider(
id="local-ollama",
adapter="ollama",
base_url="http://localhost:11434",
auth={"mode": "none", "credential_id": None},
)
_spec, request = self._resolve_and_build(provider, credential="ignored")
assert "api_key" not in request.client_options
assert "default_headers" not in request.client_options
class TestEffectiveCapabilities:
def test_intersection_rule(self):
protocol = Capabilities(tools=True, vision=False, structured_output=True)
declared = Capabilities(tools=True, vision=True, structured_output=True)
verified = Capabilities(tools=True, vision=True, structured_output=False)
effective = compute_effective_capabilities(protocol, declared, verified)
assert effective.tools is True
assert effective.vision is False
assert effective.structured_output is False
def test_all_true_when_every_layer_agrees(self):
all_true = Capabilities(tools=True, vision=True, structured_output=True)
effective = compute_effective_capabilities(all_true, all_true, all_true)
assert effective == all_true
def test_adapter_spec_type_is_schema_model(self):
for spec in adapter_specs():
assert isinstance(spec, AdapterParameterSpec)
class TestUnsupportedRuleNormalizer:
def test_declared_reject_non_auto_normalizer_executes(self, monkeypatch):
import EvoScientist.model_registry.adapters as adapters
calls = []
original = adapters._NORMALIZERS["reject_non_auto"]
def spy(name, rule, value):
calls.append((name, value))
return original(name, rule, value)
monkeypatch.setitem(adapters._NORMALIZERS, "reject_non_auto", spy)
provider = _provider()
model = _model(provider, reasoning_effort="high")
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
assert calls == [("reasoning_effort", "high")]
def test_unsupported_rule_with_passthrough_normalizer_still_rejected(self):
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
spec = spec.model_copy(deep=True)
spec.parameters["reasoning_effort"].normalizer = "identity"
provider = _provider()
model = _model(provider, reasoning_effort="low")
with pytest.raises(ModelRegistryError) as excinfo:
resolve_parameters(provider, model, spec)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_unsupported_rule_still_omits_inherited_values(self):
spec = find_adapter_spec("openai-compatible", "glm-5.2")
assert spec is not None
spec = spec.model_copy(deep=True)
spec.parameters["reasoning_effort"].normalizer = "identity"
provider = _provider()
resolved = resolve_parameters(provider, provider.models[0], spec)
assert resolved.reasoning_effort == "auto"