mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
- 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.
265 lines
8.8 KiB
Python
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")
|