diff --git a/docs/configuration.md b/docs/configuration.md index 36af22ec5..e2e384b90 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `. 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 { diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index dc88d38d5..69941828b 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -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 diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index 8898773b9..ef2a786fa 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -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: