fix(config): warn on legacy model migration

This commit is contained in:
chengyongru 2026-07-29 01:11:41 +08:00
parent 8bd53d6e26
commit 4e7c57eb1a
3 changed files with 149 additions and 4 deletions

View File

@ -1348,7 +1348,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
Model presets let you name a complete model configuration and select one per session with `/model <preset>`. Configure all model, provider, generation, context-window, and image-input settings under top-level `modelPresets`; `agents.defaults` only selects preset names.
On first load, nanobot migrates legacy model fields from `agents.defaults` and inline fallback objects in `config.json` into named presets, then atomically rewrites the file. If a concrete `modelPresets.default` and legacy direct fields both exist, the concrete preset wins and the legacy fields are removed. Legacy model fields supplied through nested `NANOBOT_AGENTS` environment settings are not supported.
On first load, nanobot migrates legacy model fields from `agents.defaults` and inline fallback objects in `config.json` into named presets, then atomically rewrites the file and logs a warning. If a concrete `modelPresets.default` and legacy direct fields both exist, the concrete preset wins and the warning explains that the conflicting legacy fields were removed. Legacy model fields supplied through nested `NANOBOT_AGENTS` environment settings are not supported and produce a warning with instructions to move them into `modelPresets`.
```json
{

View File

@ -6,6 +6,7 @@ import re
from pathlib import Path
from typing import Any
from loguru import logger
from pydantic import BaseModel, ValidationError
from pydantic_settings import SettingsError
@ -16,6 +17,7 @@ from nanobot.utils.helpers import _write_text_atomic
# Global variable to store current config path (for multi-instance support)
_current_config_path: Path | None = None
_schema_refs_ready = False
_warned_legacy_model_env = False
def set_config_path(path: Path) -> None:
@ -67,6 +69,7 @@ def load_config(config_path: Path | None = None) -> Config:
summary="Environment-based configuration is invalid.",
issues=validation_issues(exc),
) from exc
_warn_unsupported_legacy_model_env(path)
_apply_ssrf_whitelist(config)
return config
@ -110,6 +113,7 @@ def load_config(config_path: Path | None = None) -> Config:
),
)
legacy_model_migration = _legacy_model_migration_kind(data)
data, migrated = _migrate_config(data)
try:
config = Config.model_validate(data)
@ -124,7 +128,21 @@ def load_config(config_path: Path | None = None) -> Config:
if migrated:
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
if legacy_model_migration:
detail = (
"Existing modelPresets.default took precedence; conflicting "
"legacy agents.defaults fields were removed."
if legacy_model_migration == "conflict"
else "Legacy settings were converted to named model presets."
)
logger.warning(
"Migrated legacy model configuration in {}. {} "
"Review the rewritten file before downgrading nanobot.",
path,
detail,
)
_warn_unsupported_legacy_model_env(path)
_apply_ssrf_whitelist(config)
return config
@ -332,6 +350,69 @@ _LEGACY_MODEL_FIELD_ALIASES = {
}
def _legacy_model_migration_kind(data: dict[str, Any]) -> str | None:
"""Classify a pending model migration without exposing configured values."""
if not _needs_legacy_model_migration(data):
return None
agents = data.get("agents")
defaults = agents.get("defaults") if isinstance(agents, dict) else None
presets = data.get("modelPresets", data.get("model_presets"))
has_legacy_fields = isinstance(defaults, dict) and any(
alias in defaults
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
for alias in aliases
)
if has_legacy_fields and isinstance(presets, dict) and "default" in presets:
return "conflict"
return "migrated"
def _has_unsupported_legacy_model_env() -> bool:
for env_name in ("NANOBOT_AGENTS", "NANOBOT_AGENTS__DEFAULTS"):
raw = os.environ.get(env_name)
if not raw:
continue
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
continue
data = (
{"agents": parsed}
if env_name == "NANOBOT_AGENTS"
else {"agents": {"defaults": parsed}}
)
if isinstance(parsed, dict) and _needs_legacy_model_migration(data):
return True
legacy_suffixes = {
alias.upper()
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
for alias in aliases
}
prefix = "NANOBOT_AGENTS__DEFAULTS__"
for env_name in os.environ:
upper_name = env_name.upper()
if not upper_name.startswith(prefix):
continue
suffix = upper_name[len(prefix):]
if suffix in legacy_suffixes:
return True
return False
def _warn_unsupported_legacy_model_env(config_path: Path) -> None:
global _warned_legacy_model_env
if _warned_legacy_model_env or not _has_unsupported_legacy_model_env():
return
logger.warning(
"Ignoring unsupported legacy model settings from NANOBOT_AGENTS. "
"Move them to modelPresets in {}.",
config_path,
)
_warned_legacy_model_env = True
def _pop_alias(mapping: dict[str, Any], aliases: tuple[str, ...]) -> tuple[bool, Any]:
found = False
value: Any = None

View File

@ -3,11 +3,26 @@ import socket
from unittest.mock import patch
import pytest
from loguru import logger
from nanobot.config.loader import load_config, save_config
from nanobot.security.network import validate_url_target
@pytest.fixture
def warning_messages():
messages: list[str] = []
sink_id = logger.add(
lambda message: messages.append(str(message)),
level="WARNING",
format="{message}",
)
try:
yield messages
finally:
logger.remove(sink_id)
def _fake_resolve(host: str, results: list[str]):
"""Return a getaddrinfo mock that maps the given host to fake IP results."""
def _resolver(hostname, port, family=0, type_=0):
@ -127,7 +142,10 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
assert "max_messages" not in saved["agents"]["defaults"]
def test_load_config_rewrites_legacy_model_fields_to_default_preset(tmp_path) -> None:
def test_load_config_rewrites_legacy_model_fields_to_default_preset(
tmp_path,
warning_messages,
) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
@ -151,9 +169,29 @@ def test_load_config_rewrites_legacy_model_fields_to_default_preset(tmp_path) ->
assert "model" not in saved["agents"]["defaults"]
assert saved["modelPresets"]["default"]["model"] == "openai/gpt-4.1"
assert saved["modelPresets"]["default"]["temperature"] == 0
migration_warnings = [
message
for message in warning_messages
if "Migrated legacy model configuration" in message
]
assert len(migration_warnings) == 1
assert "Legacy settings were converted to named model presets" in migration_warnings[0]
assert "Review the rewritten file before downgrading" in migration_warnings[0]
load_config(config_path)
migration_warnings = [
message
for message in warning_messages
if "Migrated legacy model configuration" in message
]
assert len(migration_warnings) == 1
def test_load_config_prefers_existing_default_preset_over_legacy_fields(tmp_path) -> None:
def test_load_config_prefers_existing_default_preset_over_legacy_fields(
tmp_path,
warning_messages,
) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
@ -185,11 +223,17 @@ def test_load_config_prefers_existing_default_preset_over_legacy_fields(tmp_path
assert "model" not in saved["agents"]["defaults"]
assert "provider" not in saved["agents"]["defaults"]
assert "maxTokens" not in saved["agents"]["defaults"]
assert any(
"Existing modelPresets.default took precedence; conflicting "
"legacy agents.defaults fields were removed" in message
for message in warning_messages
)
def test_load_config_does_not_migrate_legacy_model_fields_from_environment(
tmp_path,
monkeypatch,
warning_messages,
) -> None:
monkeypatch.setenv(
"NANOBOT_AGENTS",
@ -207,9 +251,25 @@ def test_load_config_does_not_migrate_legacy_model_fields_from_environment(
assert config.resolve_default_preset().model == "anthropic/claude-opus-4-5"
assert config.resolve_default_preset().provider == "auto"
assert config.resolve_default_preset().max_tokens == 8192
assert any(
"Ignoring unsupported legacy model settings from NANOBOT_AGENTS" in message
for message in warning_messages
)
load_config(tmp_path / "another-missing-config.json")
environment_warnings = [
message
for message in warning_messages
if "Ignoring unsupported legacy model settings from NANOBOT_AGENTS" in message
]
assert len(environment_warnings) == 1
def test_load_config_migrates_inline_fallback_to_named_preset(tmp_path) -> None:
def test_load_config_migrates_inline_fallback_to_named_preset(
tmp_path,
warning_messages,
) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
@ -233,6 +293,10 @@ def test_load_config_migrates_inline_fallback_to_named_preset(tmp_path) -> None:
assert config.agents.defaults.fallback_models == ["claude-sonnet-4"]
assert saved["agents"]["defaults"]["fallbackModels"] == ["claude-sonnet-4"]
assert saved["modelPresets"]["claude-sonnet-4"]["provider"] == "anthropic"
assert any(
"Legacy settings were converted to named model presets." in message
for message in warning_messages
)
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None: