honcho/tests/llm/test_model_config.py

639 lines
23 KiB
Python

import os
from pathlib import Path
from typing import Any, cast
import pytest
from src.config import (
AppSettings,
ConfiguredEmbeddingModelSettings,
ConfiguredModelSettings,
DialecticLevelSettings,
DreamSettings,
EmbeddingSettings,
ModelConfig,
ModelOverrideSettings,
SummarySettings,
VectorStoreSettings,
load_toml_config,
resolve_embedding_model_config,
resolve_model_config,
)
def test_fallback_config_is_independent() -> None:
"""Fallback config has its own transport and reasoning params."""
from src.config import ResolvedFallbackConfig
config = ModelConfig(
model="claude-haiku-4-5",
transport="anthropic",
thinking_budget_tokens=1024,
fallback=ResolvedFallbackConfig(
model="gpt-4.1-mini",
transport="openai",
base_url="https://example.com/v1",
),
)
assert config.fallback is not None
assert config.fallback.transport == "openai"
assert config.fallback.thinking_budget_tokens is None
assert config.fallback.base_url == "https://example.com/v1"
def test_select_model_config_for_attempt_preserves_structured_output_mode() -> None:
"""The per-attempt fallback config must carry structured_output_mode.
The operator sets it on the (independent) fallback; dropping it on the final
attempt would silently send json_schema to a provider that can't parse it.
"""
from src.config import ResolvedFallbackConfig
from src.llm.runtime import select_model_config_for_attempt
config = ModelConfig(
model="gpt-5.4-mini",
transport="openai",
fallback=ResolvedFallbackConfig(
model="glm-4.6",
transport="openai",
structured_output_mode="json_object",
),
)
# Final attempt swaps to the fallback.
selected = select_model_config_for_attempt(config, attempt=3, retry_attempts=3)
assert selected.model == "glm-4.6"
assert selected.structured_output_mode == "json_object"
def test_structured_output_mode_rejected_on_non_openai_transport() -> None:
"""structured_output_mode is a no-op off the openai transport — reject it."""
with pytest.raises(ValueError, match="structured_output_mode is only supported"):
ConfiguredModelSettings(
model="claude-haiku-4-5",
transport="anthropic",
structured_output_mode="json_object",
)
def test_structured_output_mode_rejected_on_non_openai_fallback() -> None:
from src.config import FallbackModelSettings
with pytest.raises(ValueError, match="structured_output_mode is only supported"):
FallbackModelSettings(
model="gemini-2.5-pro",
transport="gemini",
structured_output_mode="json_object",
)
def test_structured_output_mode_allowed_on_openai_transport() -> None:
config = ConfiguredModelSettings(
model="glm-4.6",
transport="openai",
structured_output_mode="json_object",
)
assert config.structured_output_mode == "json_object"
def test_base_url_is_allowed_for_any_transport() -> None:
config = ModelConfig(
model="claude-haiku-4-5",
transport="anthropic",
base_url="https://anthropic-proxy.example/v1",
)
assert config.base_url == "https://anthropic-proxy.example/v1"
def test_anthropic_thinking_budget_has_minimum() -> None:
with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"):
ModelConfig(
model="claude-haiku-4-5",
transport="anthropic",
thinking_budget_tokens=512,
)
def test_reasoning_effort_alias_populates_generic_thinking_effort() -> None:
config = ModelConfig.model_validate(
{
"model": "gpt-5",
"transport": "openai",
"reasoning_effort": "minimal",
}
)
assert config.thinking_effort == "minimal"
assert config.reasoning_effort == "minimal"
def test_for_model_overrides_model_and_transport() -> None:
config = ModelConfig(
model="claude-haiku-4-5",
transport="anthropic",
)
updated = config.for_model(
"gpt-5-mini",
transport_override="openai",
)
assert updated.model == "gpt-5-mini"
assert updated.transport == "openai"
assert config.transport == "anthropic"
def test_configured_model_settings_validate_like_runtime_model_config() -> None:
with pytest.raises(ValueError, match="thinking_budget_tokens must be >= 1024"):
ConfiguredModelSettings(
model="claude-haiku-4-5",
transport="anthropic",
thinking_budget_tokens=512,
)
def test_summary_settings_accept_nested_model_config() -> None:
from src.config import FallbackModelSettings
settings = SummarySettings(
MODEL_CONFIG=ConfiguredModelSettings(
model="claude-haiku-4-5",
transport="anthropic",
fallback=FallbackModelSettings(
model="gemini-2.5-pro",
transport="gemini",
),
thinking_budget_tokens=1024,
),
)
assert settings.MODEL_CONFIG.model == "claude-haiku-4-5"
assert settings.MODEL_CONFIG.transport == "anthropic"
assert settings.MODEL_CONFIG.fallback is not None
assert settings.MODEL_CONFIG.fallback.model == "gemini-2.5-pro"
assert settings.MODEL_CONFIG.fallback.transport == "gemini"
assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024
def test_resolve_model_config_reads_override_env_and_provider_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SUMMARY_LOCAL_API_KEY", "test-key")
configured = ConfiguredModelSettings(
model="my-local-model",
transport="openai",
overrides=ModelOverrideSettings(
api_key_env="SUMMARY_LOCAL_API_KEY",
base_url="http://localhost:8000/v1",
provider_params={"verbosity": "low"},
),
)
resolved = resolve_model_config(configured)
assert resolved.api_key == "test-key"
assert resolved.base_url == "http://localhost:8000/v1"
assert resolved.provider_params == {"verbosity": "low"}
def test_resolve_embedding_model_config_reads_override_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("EMBEDDING_LOCAL_API_KEY", "embed-key")
configured = ConfiguredEmbeddingModelSettings(
transport="openai",
model="text-embedding-3-small",
overrides=ModelOverrideSettings(
api_key_env="EMBEDDING_LOCAL_API_KEY",
base_url="http://localhost:8000/v1",
),
)
resolved = resolve_embedding_model_config(configured)
assert resolved.api_key == "embed-key"
assert resolved.base_url == "http://localhost:8000/v1"
def test_dialectic_level_settings_accepts_nested_model_config() -> None:
from src.config import FallbackModelSettings
settings = DialecticLevelSettings(
MODEL_CONFIG=ConfiguredModelSettings(
model="claude-haiku-4-5",
transport="anthropic",
fallback=FallbackModelSettings(
model="gemini-2.5-pro",
transport="gemini",
),
thinking_budget_tokens=1024,
),
MAX_TOOL_ITERATIONS=2,
)
resolved = resolve_model_config(settings.MODEL_CONFIG)
assert resolved.model == "claude-haiku-4-5"
assert resolved.transport == "anthropic"
assert resolved.fallback is not None
assert resolved.fallback.model == "gemini-2.5-pro"
assert resolved.fallback.transport == "gemini"
def test_dialectic_level_settings_require_nested_model_config() -> None:
with pytest.raises(ValueError, match="Field required"):
DialecticLevelSettings.model_validate({"MAX_TOOL_ITERATIONS": 2})
def test_dialectic_level_settings_reject_legacy_flat_model_shape() -> None:
with pytest.raises(ValueError, match="Field required"):
DialecticLevelSettings.model_validate(
{
"MODEL": "claude-haiku-4-5",
"THINKING_BUDGET_TOKENS": 1024,
"MAX_TOOL_ITERATIONS": 2,
}
)
def test_legacy_prefixed_model_strings_are_normalized() -> None:
config = ModelConfig.model_validate({"model": "gemini/gemini-2.5-flash"})
configured = ConfiguredModelSettings.model_validate(
{"model": "anthropic/claude-haiku-4-5"}
)
assert config.transport == "gemini"
assert config.model == "gemini-2.5-flash"
assert configured.transport == "anthropic"
assert configured.model == "claude-haiku-4-5"
def test_dream_specialist_model_configs_are_independent() -> None:
"""Specialist configs carry their own defaults and don't inherit from a parent."""
dream = DreamSettings(
DEDUCTION_MODEL_CONFIG=ConfiguredModelSettings(
model="claude-haiku-4-5",
transport="anthropic",
thinking_budget_tokens=2048,
),
INDUCTION_MODEL_CONFIG=ConfiguredModelSettings(
model="claude-opus-4-1",
transport="anthropic",
max_output_tokens=8000,
),
)
assert dream.DEDUCTION_MODEL_CONFIG.model == "claude-haiku-4-5"
assert dream.DEDUCTION_MODEL_CONFIG.thinking_budget_tokens == 2048
assert dream.DEDUCTION_MODEL_CONFIG.max_output_tokens is None
assert dream.INDUCTION_MODEL_CONFIG.model == "claude-opus-4-1"
assert dream.INDUCTION_MODEL_CONFIG.max_output_tokens == 8000
assert dream.INDUCTION_MODEL_CONFIG.thinking_budget_tokens is None
def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None:
settings = AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=True),
)
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048
assert settings.VECTOR_STORE.DIMENSIONS == 2048
def test_app_settings_explicit_vector_store_dimensions_warns_and_overrides() -> None:
"""VECTOR_STORE.DIMENSIONS is deprecated: EMBEDDING.VECTOR_DIMENSIONS wins
and the operator gets a DeprecationWarning if they set it explicitly."""
import warnings
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
settings = AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
VECTOR_STORE=VectorStoreSettings(
TYPE="lancedb",
MIGRATED=True,
DIMENSIONS=1536,
),
)
messages = [
str(w.message) for w in captured if issubclass(w.category, DeprecationWarning)
]
assert any(
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
), f"expected deprecation warning, got {messages!r}"
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048
assert settings.VECTOR_STORE.DIMENSIONS == 2048, (
"EMBEDDING.VECTOR_DIMENSIONS should always overwrite the operator-supplied "
"VECTOR_STORE.DIMENSIONS value"
)
def test_app_settings_accepts_non_1536_with_any_vector_store_configuration() -> None:
"""The dim-vs-MIGRATED guard was removed; the runtime startup schema
validator (src/startup/embedding_validator.py) is the new safety net.
Construction must succeed for every combination at config time."""
from typing import Literal
combos: list[tuple[Literal["pgvector", "turbopuffer", "lancedb"], bool]] = [
("pgvector", True),
("pgvector", False),
("lancedb", True),
("lancedb", False),
("turbopuffer", True),
("turbopuffer", False),
]
for store_type, migrated in combos:
# Turbopuffer's model_validator requires TURBOPUFFER_API_KEY whenever
# TYPE="turbopuffer"; supply a dummy value so the test exercises the
# dim-acceptance path rather than the api-key guard.
vs_kwargs: dict[str, Any] = {"TYPE": store_type, "MIGRATED": migrated}
if store_type == "turbopuffer":
vs_kwargs["TURBOPUFFER_API_KEY"] = "test-key"
settings = AppSettings(
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=768),
VECTOR_STORE=VectorStoreSettings(**vs_kwargs),
)
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 768
assert store_type == settings.VECTOR_STORE.TYPE
assert settings.VECTOR_STORE.MIGRATED is migrated
def test_config_toml_example_uses_nested_model_config_sections() -> None:
config_path = Path(__file__).resolve().parents[2] / "config.toml.example"
config_data = load_toml_config(str(config_path))
deriver_config = ConfiguredModelSettings.model_validate(
config_data["deriver"]["model_config"]
)
minimal_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["minimal"]
)
low_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["low"]
)
max_level = DialecticLevelSettings.model_validate(
config_data["dialectic"]["levels"]["max"]
)
embedding_config = ConfiguredEmbeddingModelSettings.model_validate(
config_data["embedding"]["model_config"]
)
summary_config = ConfiguredModelSettings.model_validate(
config_data["summary"]["model_config"]
)
deduction_model_config = ConfiguredModelSettings.model_validate(
config_data["dream"]["deduction_model_config"]
)
induction_model_config = ConfiguredModelSettings.model_validate(
config_data["dream"]["induction_model_config"]
)
dream = DreamSettings.model_validate(
{
"DEDUCTION_MODEL_CONFIG": deduction_model_config,
"INDUCTION_MODEL_CONFIG": induction_model_config,
}
)
# config.toml.example ships the same minimal defaults the app uses:
# transport=openai, model=gpt-5.4-mini across every text-generation
# feature, with embeddings on openai/text-embedding-3-small. Asserting
# these keeps the example file and the in-code defaults in lockstep.
assert deriver_config.transport == "openai"
assert deriver_config.model == "gpt-5.4-mini"
assert deriver_config.thinking_budget_tokens is None
assert minimal_level.MODEL_CONFIG.model == "gpt-5.4-mini"
assert minimal_level.MODEL_CONFIG.transport == "openai"
assert minimal_level.TOOL_CHOICE == "auto"
assert low_level.TOOL_CHOICE == "auto"
assert max_level.MODEL_CONFIG.model == "gpt-5.4-mini"
assert max_level.MODEL_CONFIG.transport == "openai"
assert max_level.MODEL_CONFIG.thinking_budget_tokens is None
assert embedding_config.transport == "openai"
assert embedding_config.model == "text-embedding-3-small"
assert summary_config.model == "gpt-5.4-mini"
assert summary_config.transport == "openai"
assert dream.DEDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini"
assert dream.INDUCTION_MODEL_CONFIG.model == "gpt-5.4-mini"
def test_env_template_uses_nested_model_config_keys() -> None:
env_template_path = Path(__file__).resolve().parents[2] / ".env.template"
env_template = env_template_path.read_text()
assert "EMBEDDING_MODEL_CONFIG__MODEL" in env_template
assert "EMBEDDING_VECTOR_DIMENSIONS" in env_template
assert "DERIVER_MODEL_CONFIG__MODEL" in env_template
assert "DIALECTIC_LEVELS__minimal__MODEL_CONFIG__MODEL" in env_template
assert "DIALECTIC_LEVELS__minimal__TOOL_CHOICE=auto" in env_template
assert "DIALECTIC_LEVELS__low__TOOL_CHOICE=auto" in env_template
assert "SUMMARY_MODEL_CONFIG__MODEL" in env_template
assert "DREAM_DEDUCTION_MODEL_CONFIG__MODEL" in env_template
assert "DERIVER_PROVIDER=" not in env_template
assert "SUMMARY_PROVIDER=" not in env_template
assert "DIALECTIC_LEVELS__minimal__PROVIDER=" not in env_template
assert "DREAM_PROVIDER=" not in env_template
assert "DREAM_DEDUCTION_MODEL=" not in env_template
def _clear_deriver_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Strip any DERIVER_MODEL_CONFIG__* env that would interfere with
direct-construction tests."""
for name in list(os.environ):
if name.startswith("DERIVER_MODEL_CONFIG__") or name == "DERIVER_MODEL_CONFIG":
monkeypatch.delenv(name, raising=False)
def test_partial_env_override_of_transport_drops_default_thinking_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A partial env override of transport must not leak the default's thinking
params into a transport that rejects them.
Regression: setting DERIVER_MODEL_CONFIG__TRANSPORT=openai +
DERIVER_MODEL_CONFIG__MODEL=gpt-4.1-mini (without clearing the default
thinking_budget_tokens=1024 carried over from the gemini default) used to
produce a merged ConfiguredModelSettings with thinking_budget_tokens=1024,
which the OpenAI backend then rejected at call time.
"""
from src.config import DeriverSettings
_clear_deriver_env(monkeypatch)
# Exercise the @model_validator(mode="before") merge path with a raw dict
# — pyright can't see through the pre-validator that accepts dict input.
settings = DeriverSettings(
MODEL_CONFIG={"transport": "openai", "model": "gpt-4.1-mini"}, # pyright: ignore[reportArgumentType]
)
assert settings.MODEL_CONFIG.transport == "openai"
assert settings.MODEL_CONFIG.model == "gpt-4.1-mini"
assert settings.MODEL_CONFIG.thinking_budget_tokens is None
assert settings.MODEL_CONFIG.thinking_effort is None
def test_partial_env_override_same_transport_keeps_default_thinking_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When env preserves the default transport, default thinking params still
apply — we only strip on actual transport change.
The app-level defaults are intentionally minimal (transport + model only)
to avoid clobbering operator config, so this test patches in a deliberately
rich default to exercise the merge-preservation behavior.
"""
from src.config import ConfiguredModelSettings, DeriverSettings
_clear_deriver_env(monkeypatch)
def _rich_default() -> ConfiguredModelSettings:
return ConfiguredModelSettings(
transport="gemini",
model="gemini-2.5-flash-lite",
thinking_budget_tokens=1024,
max_output_tokens=4096,
)
monkeypatch.setattr(DeriverSettings, "_MODEL_CONFIG_DEFAULT", _rich_default)
settings = DeriverSettings(
MODEL_CONFIG={"model": "gemini-2.5-pro"}, # pyright: ignore[reportArgumentType]
)
assert settings.MODEL_CONFIG.transport == "gemini"
assert settings.MODEL_CONFIG.model == "gemini-2.5-pro"
assert settings.MODEL_CONFIG.thinking_budget_tokens == 1024
assert settings.MODEL_CONFIG.max_output_tokens == 4096
def test_explicit_thinking_effort_survives_transport_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""User-set thinking params in the override are always preserved."""
from src.config import DeriverSettings
_clear_deriver_env(monkeypatch)
settings = DeriverSettings(
MODEL_CONFIG={ # pyright: ignore[reportArgumentType]
"transport": "openai",
"model": "gpt-5",
"thinking_effort": "high",
},
)
assert settings.MODEL_CONFIG.transport == "openai"
assert settings.MODEL_CONFIG.thinking_effort == "high"
assert settings.MODEL_CONFIG.thinking_budget_tokens is None
def test_dialectic_level_transport_override_drops_default_thinking_params(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Same leak existed in DialecticSettings._merge_level_defaults.
Regression: when a level default has thinking_budget_tokens=0 under a
gemini transport and env flips the override to openai, the 0 used to leak
through and trip the OpenAI backend's thinking-param rejection.
The app-level defaults are intentionally minimal (transport + model only)
to avoid clobbering operator config, so this test patches in a rich
level default to exercise the strip-on-transport-change behavior.
Exercises the before-validator directly to avoid DialecticSettings'
"all 5 levels required" constraint.
"""
from src.config import (
ConfiguredModelSettings,
DialecticLevelSettings,
DialecticSettings,
)
def _rich_levels() -> dict[str, DialecticLevelSettings]:
return {
"minimal": DialecticLevelSettings(
MODEL_CONFIG=ConfiguredModelSettings(
transport="gemini",
model="gemini-2.5-flash-lite",
thinking_budget_tokens=0,
),
MAX_TOOL_ITERATIONS=1,
MAX_OUTPUT_TOKENS=250,
TOOL_CHOICE="any",
),
}
monkeypatch.setattr("src.config._default_dialectic_levels", _rich_levels)
data: dict[str, object] = {
"LEVELS": {
"minimal": {
"MODEL_CONFIG": {
"transport": "openai",
"model": "gpt-4.1-mini",
}
}
}
}
# The @model_validator decorator wraps the classmethod in a descriptor proxy
# that pyright can't see as callable; at runtime pydantic routes it correctly.
merged = cast(
dict[str, Any],
DialecticSettings._merge_level_defaults(data), # pyright: ignore[reportPrivateUsage, reportCallIssue]
)
levels = cast(dict[str, dict[str, Any]], merged["LEVELS"])
minimal_mc = cast(dict[str, Any], levels["minimal"]["MODEL_CONFIG"])
assert minimal_mc["transport"] == "openai"
assert minimal_mc["model"] == "gpt-4.1-mini"
assert "thinking_budget_tokens" not in minimal_mc
assert "thinking_effort" not in minimal_mc
def test_dialectic_settings_backfills_missing_levels(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Operators only need to override the levels they care about.
Env-var overrides replace the LEVELS dict wholesale (bypassing the
default_factory), so without a backfill the unmentioned levels would be
dropped and _validate_all_levels_present would fail.
"""
from src.config import (
DialecticSettings,
_default_dialectic_levels, # pyright: ignore[reportPrivateUsage]
)
for key in list(os.environ):
if key.startswith("DIALECTIC_LEVELS"):
monkeypatch.delenv(key)
settings = DialecticSettings(
LEVELS={ # pyright: ignore[reportArgumentType]
"low": {
"MODEL_CONFIG": {
"transport": "anthropic",
"model": "claude-haiku-4-5-20251001",
"thinking_budget_tokens": 1024,
},
"MAX_OUTPUT_TOKENS": 2500,
}
}
)
assert set(settings.LEVELS.keys()) == {"minimal", "low", "medium", "high", "max"}
assert settings.LEVELS["low"].MODEL_CONFIG.transport == "anthropic"
assert settings.LEVELS["low"].MODEL_CONFIG.model == "claude-haiku-4-5-20251001"
assert settings.LEVELS["low"].MAX_OUTPUT_TOKENS == 2500
# Backfilled levels come from _default_dialectic_levels()
defaults = _default_dialectic_levels()
assert (
settings.LEVELS["minimal"].MAX_TOOL_ITERATIONS
== defaults["minimal"].MAX_TOOL_ITERATIONS
)
assert (
settings.LEVELS["max"].MAX_TOOL_ITERATIONS
== defaults["max"].MAX_TOOL_ITERATIONS
)