nanobot/tests/config/test_model_presets.py
chengyongru 1f031db66b feat(config): add model preset support for runtime model switching
Add ModelPresetConfig schema and model_presets dictionary to config,
enabling named bundles of model parameters (model, temperature,
max_tokens, reasoning_effort, context_window_tokens) that can be
switched atomically at runtime via the self tool.
2026-05-08 20:19:43 +08:00

207 lines
6.6 KiB
Python

from nanobot.config.schema import Config, ModelPresetConfig
def test_model_preset_config_accepts_model_and_provider_separately() -> None:
preset = ModelPresetConfig(model="gpt-5", provider="openai")
assert preset.model == "gpt-5"
assert preset.provider == "openai"
def test_model_preset_config_defaults() -> None:
preset = ModelPresetConfig(model="test-model")
assert preset.provider == "auto"
assert preset.max_tokens == 8192
assert preset.context_window_tokens == 65_536
assert preset.temperature == 0.1
assert preset.reasoning_effort is None
def test_model_preset_config_all_fields() -> None:
preset = ModelPresetConfig(
model="deepseek-r1",
provider="deepseek",
max_tokens=16384,
context_window_tokens=131072,
temperature=0.2,
reasoning_effort="high",
)
assert preset.model == "deepseek-r1"
assert preset.provider == "deepseek"
assert preset.max_tokens == 16384
assert preset.context_window_tokens == 131072
assert preset.temperature == 0.2
assert preset.reasoning_effort == "high"
def test_config_accepts_model_presets_dict() -> None:
cfg = Config(model_presets={
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai", max_tokens=16384),
"ds": ModelPresetConfig(model="deepseek-chat", provider="deepseek"),
})
assert "gpt5" in cfg.model_presets
assert cfg.model_presets["gpt5"].max_tokens == 16384
assert cfg.model_presets["ds"].model == "deepseek-chat"
def test_resolve_preset_returns_preset_values() -> None:
cfg = Config.model_validate({
"model_presets": {
"gpt5": {
"model": "gpt-5",
"provider": "openai",
"max_tokens": 16384,
"context_window_tokens": 128000,
"temperature": 0.2,
},
},
"agents": {"defaults": {"model_preset": "gpt5"}},
})
r = cfg.resolve_preset()
assert r.model == "gpt-5"
assert r.provider == "openai"
assert r.max_tokens == 16384
assert r.context_window_tokens == 128000
assert r.temperature == 0.2
def test_resolve_preset_ignores_old_config_fields() -> None:
"""Preset wins completely — old config remnants are ignored."""
cfg = Config.model_validate({
"model_presets": {
"gpt5": {
"model": "gpt-5",
"provider": "openai",
"max_tokens": 16384,
"context_window_tokens": 128000,
"temperature": 0.2,
},
},
"agents": {
"defaults": {
"model_preset": "gpt5",
"model": "old-model",
"temperature": 0.5,
},
},
})
r = cfg.resolve_preset()
assert r.model == "gpt-5"
assert r.temperature == 0.2
assert r.max_tokens == 16384
def test_preset_not_found_raises_error() -> None:
import pytest
with pytest.raises(Exception, match="model_preset.*not found"):
Config.model_validate({
"model_presets": {},
"agents": {"defaults": {"model_preset": "nonexistent"}},
})
def test_resolve_preset_without_preset_returns_defaults() -> None:
"""Backward compat: no preset → resolve_preset returns individual field values."""
cfg = Config.model_validate({
"agents": {"defaults": {"model": "deepseek-chat"}},
})
r = cfg.resolve_preset()
assert r.model == "deepseek-chat"
assert r.max_tokens == 8192
def test_agent_loop_stores_model_presets() -> None:
from pathlib import Path
from unittest.mock import MagicMock
from nanobot.agent.loop import AgentLoop
presets = {
"gpt5": ModelPresetConfig(model="gpt-5", provider="openai"),
}
provider = MagicMock()
provider.get_default_model.return_value = "test"
loop = AgentLoop(
bus=MagicMock(),
provider=provider,
workspace=Path("/tmp/test"),
model_presets=presets,
)
assert loop.model_presets == presets
def test_resolve_preset_with_reasoning_effort() -> None:
cfg = Config.model_validate({
"model_presets": {
"ds-r1": {
"model": "deepseek-r1",
"provider": "deepseek",
"reasoning_effort": "high",
},
},
"agents": {"defaults": {"model_preset": "ds-r1"}},
})
assert cfg.resolve_preset().reasoning_effort == "high"
def test_preset_routes_to_correct_provider() -> None:
"""resolve_preset + _match_provider uses the preset's model+provider."""
cfg = Config.model_validate({
"model_presets": {
"ds": {"model": "deepseek-chat", "provider": "deepseek"},
},
"providers": {"deepseek": {"api_key": "test-key"}},
"agents": {"defaults": {"model_preset": "ds"}},
})
provider_name = cfg.get_provider_name()
assert provider_name == "deepseek"
def test_preset_with_auto_provider_uses_keyword_matching() -> None:
cfg = Config.model_validate({
"model_presets": {
"auto-ds": {"model": "deepseek-chat", "provider": "auto"},
},
"providers": {"deepseek": {"api_key": "test-key"}},
"agents": {"defaults": {"model_preset": "auto-ds"}},
})
provider_name = cfg.get_provider_name()
assert provider_name == "deepseek"
def test_backward_compat_no_preset() -> None:
"""Existing configs without model_presets work exactly as before."""
cfg = Config.model_validate({
"providers": {"anthropic": {"api_key": "test-key"}},
"agents": {"defaults": {"model": "anthropic/claude-opus-4-5"}},
})
assert cfg.resolve_preset().model == "anthropic/claude-opus-4-5"
assert cfg.agents.defaults.model_preset is None
assert cfg.get_provider_name() == "anthropic"
def test_resolve_preset_overrides_all_model_fields() -> None:
"""When model_preset is set, resolve_preset returns preset values, not individual fields."""
cfg = Config.model_validate({
"model_presets": {
"gpt5": {"model": "gpt-5", "provider": "openai", "max_tokens": 16384},
},
"providers": {"openai": {"api_key": "test-key"}},
"agents": {
"defaults": {
"model_preset": "gpt5",
"model": "legacy-model",
"max_tokens": 4096,
},
},
})
r = cfg.resolve_preset()
assert r.model == "gpt-5"
assert r.provider == "openai"
assert r.max_tokens == 16384
def test_empty_model_presets_dict_is_harmless() -> None:
cfg = Config.model_validate({"model_presets": {}})
assert cfg.resolve_preset().model == "anthropic/claude-opus-4-5"