nanobot/tests/config/test_model_presets.py
chengyongru 0bc42e2ab2 refactor: restrict fallback_models to preset-only and clean up provider factory
- Restrict fallback_models to only reference preset names in model_presets.
- Add schema validation to reject unknown preset names in fallback_models.
- Remove build_provider_for_model() since bare model fallback is no longer supported.
- Simplify make_provider_factory() to only look up presets by name.
- Update onboard UI to remove "Add custom model" option from fallback chain.
- Update tests to use preset names instead of bare model strings in fallback chains.
- Fix test imports referencing deleted _make_provider function.
2026-05-08 20:24:24 +08:00

265 lines
8.8 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_fallback_presets_invalid_preset_raises_error() -> None:
import pytest
with pytest.raises(Exception, match="fallback_presets.*not found"):
Config.model_validate({
"model_presets": {
"valid": {"model": "gpt-4"},
},
"agents": {"defaults": {"fallback_presets": ["invalid_preset"]}},
})
def test_resolve_preset_without_preset_returns_defaults() -> None:
"""Backward compat: no explicit preset → resolve_preset returns the auto-created 'default' preset."""
cfg = Config.model_validate({
"agents": {"defaults": {"model": "deepseek-chat"}},
})
assert cfg.agents.defaults.model_preset == "default"
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 are automatically promoted to the 'default' preset."""
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 == "default"
assert "default" in cfg.model_presets
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"
def test_factory_uses_preset_provider_not_defaults() -> None:
"""When creating a provider for a non-active preset, the preset's own provider must be used."""
from nanobot.providers.factory import make_provider_factory
cfg = Config.model_validate({
"model_presets": {
"kimi": {"model": "kimi-k2.6", "provider": "moonshot"},
"zhipu": {"model": "glm-5.1", "provider": "zhipu"},
},
"providers": {
"moonshot": {"api_key": "moonshot-key", "api_base": "https://api.moonshot.ai/v1"},
"zhipu": {"api_key": "zhipu-key", "api_base": "https://open.bigmodel.cn/api/paas/v4"},
},
"agents": {"defaults": {"model_preset": "kimi"}},
})
factory = make_provider_factory(cfg)
zhipu_provider = factory("zhipu")
assert zhipu_provider.api_base == "https://open.bigmodel.cn/api/paas/v4"
assert getattr(zhipu_provider, "api_key", None) == "zhipu-key"
# Also verify the active preset provider is still correct
moonshot_provider = factory("kimi")
assert moonshot_provider.api_base == "https://api.moonshot.ai/v1"
def test_factory_rejects_unknown_preset_name() -> None:
"""Factory must raise ValueError when asked for a preset not in model_presets."""
import pytest
from nanobot.providers.factory import make_provider_factory
cfg = Config.model_validate({
"model_presets": {
"known": {"model": "gpt-4", "provider": "openai"},
},
"providers": {"openai": {"api_key": "test-key"}},
})
factory = make_provider_factory(cfg)
with pytest.raises(ValueError, match="Preset 'unknown' not found"):
factory("unknown")