b2e28249fd
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.
695 lines
27 KiB
Python
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"
|