af4ae1aef5
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.
313 lines
12 KiB
Python
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
|