Files
EvoScientist/tests/test_model_factory.py
T
m4 af4ae1aef5 feat(model-registry): add adapter parameter contracts and build_chat_model factory
Add the Task 3 parameter contract layer (design doc 6.1-6.4):

- adapters.py: versioned built-in contracts for the five phase-1
  adapters plus the openai-compatible/glm-5.2 model-specific contract
  (verbatim section 6.2 values); exact > longest glob > generic
  matching with spec_revision pinning; resolve_parameters implementing
  the section 6.1 inherit/omit semantics, contract validation with
  stable error codes, and named normalizers (identity,
  clamp_to_model_limit, omit_when_none, omit_when_auto,
  reject_non_auto); Adapter.build_request as the single entry point
  mapping ResolvedModelConfig to {client_options, request_options};
  compute_effective_capabilities (protocol AND declared AND verified).
- factory.py: build_chat_model(resolved_config, http_client, *,
  credential=None) with no **kwargs and no setdefault merging; injects
  the safe HTTP client into ChatOpenAI/ChatAnthropic/ChatOllama, never
  reads provider API-key environment variables, and strips the
  OLLAMA_API_KEY authorization header for mode=none adapters.
- tests: per-adapter request-capturing fakes plus an httpx.MockTransport
  outbound capture proving registry resolution matches the wire request.
2026-07-20 22:17:11 +08:00

313 lines
12 KiB
Python

"""Tests for build_chat_model (design doc section 6.4).
``build_chat_model`` is the single model-construction entry point: it calls
``adapter.build_request`` for the resolved configuration, injects the Task 2
safe HTTP client, and never falls back to provider API-key environment
variables. Per-adapter fakes capture the construction arguments and request
options to prove the registry resolution matches the outbound request.
"""
from __future__ import annotations
import json
import httpx
import pytest
from langchain_core.messages import HumanMessage
from EvoScientist.model_registry import factory
from EvoScientist.model_registry.errors import (
ADAPTER_NOT_SUPPORTED,
CREDENTIAL_NOT_CONFIGURED,
ModelRegistryError,
)
from EvoScientist.model_registry.factory import build_chat_model
from EvoScientist.model_registry.schemas import ResolvedModelConfig
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)
def _http_client() -> httpx.Client:
return httpx.Client(transport=httpx.MockTransport(lambda request: None))
class _FakeChatModel:
"""Captures construction arguments instead of opening a connection."""
def __init__(self, **kwargs):
self.kwargs = kwargs
class _FakeBuilder:
"""Records the options and HTTP client a builder receives."""
def __init__(self):
self.calls = []
def __call__(self, options, http_client):
self.calls.append((dict(options), http_client))
return _FakeChatModel(**options)
@pytest.fixture
def fake_builders(monkeypatch):
builders = {}
for name in ("ChatOpenAI", "ChatAnthropic", "ChatOllama"):
builder = _FakeBuilder()
builders[name] = builder
monkeypatch.setitem(factory.CHAT_MODEL_BUILDERS, name, builder)
return builders
class TestInterfaceContract:
def test_rejects_extra_keyword_arguments(self):
with pytest.raises(TypeError):
build_chat_model(_resolved_config(), _http_client(), unexpected_option=True)
def test_rejects_positional_credential(self):
with pytest.raises(TypeError):
build_chat_model(_resolved_config(), _http_client(), "secret")
def test_rejects_non_resolved_config(self):
with pytest.raises(TypeError):
build_chat_model({"adapter_id": "openai"}, _http_client())
def test_rejects_non_httpx_client(self):
with pytest.raises(TypeError):
build_chat_model(_resolved_config(), object())
def test_unknown_adapter_spec_revision_fails_loudly(self):
resolved = _resolved_config(adapter_spec_revision=99)
with pytest.raises(ModelRegistryError) as excinfo:
build_chat_model(resolved, _http_client(), credential="secret")
assert excinfo.value.code == ADAPTER_NOT_SUPPORTED
class TestEnvironmentIsolation:
def test_openai_api_key_env_is_never_read(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
with pytest.raises(ModelRegistryError) as excinfo:
build_chat_model(_resolved_config(), _http_client())
assert excinfo.value.code == CREDENTIAL_NOT_CONFIGURED
def test_explicit_credential_wins_over_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
model = build_chat_model(
_resolved_config(), _http_client(), credential="real-key"
)
assert model.openai_api_key.get_secret_value() == "real-key"
def test_anthropic_api_key_env_is_never_read(self, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "env-ant-key")
resolved = _resolved_config(
adapter_id="anthropic",
upstream_model_id="claude-x",
base_url="https://api.anthropic.com",
)
with pytest.raises(ModelRegistryError) as excinfo:
build_chat_model(resolved, _http_client())
assert excinfo.value.code == CREDENTIAL_NOT_CONFIGURED
def test_ollama_mode_none_strips_env_authorization(self, monkeypatch):
monkeypatch.setenv("OLLAMA_API_KEY", "ollama-env-secret")
resolved = _resolved_config(
adapter_id="ollama",
upstream_model_id="llama3",
base_url="http://localhost:11434",
auth_ref={"mode": "none", "credential_id": None},
)
model = build_chat_model(resolved, _http_client())
headers = model._client._client.headers
assert "authorization" not in headers
async_headers = model._async_client._client.headers
assert "authorization" not in async_headers
class TestPerAdapterConstruction:
def test_openai_compatible_glm_options(self, fake_builders):
client = _http_client()
build_chat_model(_resolved_config(), client, credential="test-secret")
options, seen_client = fake_builders["ChatOpenAI"].calls[0]
assert seen_client is client
assert options == {
"model": "glm-5.2",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"timeout": 120,
"max_retries": 2,
"api_key": "test-secret",
"max_tokens": 32768,
"temperature": 0.7,
"top_p": 0.95,
}
def test_openai_adapter(self, fake_builders):
resolved = _resolved_config(
adapter_id="openai",
upstream_model_id="gpt-x",
base_url="https://api.openai.com/v1",
)
build_chat_model(resolved, _http_client(), credential="sk-test")
options, _ = fake_builders["ChatOpenAI"].calls[0]
assert options["model"] == "gpt-x"
assert options["max_tokens"] == 32768
assert options["api_key"] == "sk-test"
def test_anthropic_adapter(self, fake_builders):
resolved = _resolved_config(
adapter_id="anthropic",
upstream_model_id="claude-x",
base_url="https://api.anthropic.com",
)
client = _http_client()
build_chat_model(resolved, client, credential="sk-ant")
options, seen_client = fake_builders["ChatAnthropic"].calls[0]
assert seen_client is client
assert options["model"] == "claude-x"
assert options["max_tokens"] == 32768
assert options["api_key"] == "sk-ant"
assert "reasoning_effort" not in options
def test_anthropic_compatible_adapter(self, fake_builders):
resolved = _resolved_config(
adapter_id="anthropic-compatible",
upstream_model_id="claude-x",
base_url="https://gateway.example.com",
)
build_chat_model(resolved, _http_client(), credential="sk-ant")
options, _ = fake_builders["ChatAnthropic"].calls[0]
assert options["base_url"] == "https://gateway.example.com"
assert options["max_tokens"] == 32768
def test_ollama_adapter(self, fake_builders):
resolved = _resolved_config(
adapter_id="ollama",
upstream_model_id="llama3",
base_url="http://localhost:11434",
auth_ref={"mode": "none", "credential_id": None},
)
client = _http_client()
build_chat_model(resolved, client)
options, seen_client = fake_builders["ChatOllama"].calls[0]
assert seen_client is client
assert options["model"] == "llama3"
assert options["base_url"] == "http://localhost:11434"
assert options["num_predict"] == 32768
assert options["client_kwargs"] == {"timeout": 120}
assert "api_key" not in options
class TestSafeHttpClientInjection:
def test_chat_openai_receives_safe_client(self):
client = _http_client()
model = build_chat_model(_resolved_config(), client, credential="key")
assert model.http_client is client
def test_chat_anthropic_egress_uses_safe_client(self):
client = _http_client()
resolved = _resolved_config(
adapter_id="anthropic",
upstream_model_id="claude-x",
base_url="https://api.anthropic.com",
)
model = build_chat_model(resolved, client, credential="sk-ant")
assert model._client._client is client
def test_chat_ollama_egress_uses_safe_transport(self):
client = _http_client()
resolved = _resolved_config(
adapter_id="ollama",
upstream_model_id="llama3",
base_url="http://localhost:11434",
auth_ref={"mode": "none", "credential_id": None},
)
model = build_chat_model(resolved, client)
assert model._client._client._transport is client._transport
class TestOutboundRequestCapture:
def test_glm_request_matches_resolved_config(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
captured = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
return httpx.Response(
200,
json={
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1,
"model": "glm-5.2",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 3,
"completion_tokens": 1,
"total_tokens": 4,
},
},
)
client = httpx.Client(transport=httpx.MockTransport(handler))
model = build_chat_model(_resolved_config(), client, credential="real-key")
response = model.invoke([HumanMessage(content="hi")])
assert response.content == "hello"
assert len(captured) == 1
request = captured[0]
assert request.url.path == "/api/paas/v4/chat/completions"
assert request.headers["authorization"] == "Bearer real-key"
body = json.loads(request.content)
assert body["model"] == "glm-5.2"
# The contract maps max_output_tokens to the LangChain `max_tokens`
# constructor option; LangChain encodes it as max_completion_tokens
# on the wire.
assert body["max_completion_tokens"] == 32768
assert "max_tokens" not in body
assert body["temperature"] == 0.7
assert body["top_p"] == 0.95
assert "reasoning_effort" not in body
assert "reasoning" not in body