639 lines
23 KiB
Python
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
|
|
)
|