From d4ade8f68010ea4b5fa27b6f2dd36537f91b5610 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 18 May 2026 14:03:29 +0800 Subject: [PATCH] feat(cli): add Model Preset wizard to onboard Extract the [M] Model Presets interactive CRUD screen from PR #3696 and adapt it to the current main branch schema (fallback_models instead of fallback_presets). Adds preset cache, field handlers for model_preset/provider/fallback_models, and 9 new tests. --- nanobot/cli/onboard.py | 218 ++++++++++++++++++++++++++- tests/agent/test_onboard_logic.py | 239 ++++++++++++++++++++++++++++++ 2 files changed, 456 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 96c97c088..9f5fc0a88 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -22,7 +22,7 @@ from nanobot.cli.models import ( get_model_suggestions, ) from nanobot.config.loader import get_config_path, load_config -from nanobot.config.schema import Config +from nanobot.config.schema import Config, ModelPresetConfig console = Console() @@ -49,6 +49,10 @@ _SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = { _BACK_PRESSED = object() # Sentinel value for back navigation +# Cache of model-preset names populated at runtime so that field handlers can +# offer existing presets as choices (e.g. AgentDefaults.model_preset). +_MODEL_PRESET_CACHE: set[str] = set() + def _get_questionary(): """Return questionary or raise a clear error when wizard deps are unavailable.""" @@ -588,9 +592,102 @@ def _handle_context_window_field( setattr(working_model, field_name, new_value) +def _handle_model_preset_field( + working_model: BaseModel, field_name: str, field_display: str, current_value: Any +) -> None: + """Handle the 'model_preset' field with a list of existing presets.""" + preset_names = sorted(_MODEL_PRESET_CACHE) + choices = ["(clear/unset)"] + preset_names + default_choice = str(current_value) if current_value else "(clear/unset)" + new_value = _select_with_back(field_display, choices, default=default_choice) + if new_value is _BACK_PRESSED: + return + if new_value == "(clear/unset)": + setattr(working_model, field_name, None) + elif new_value is not None: + setattr(working_model, field_name, new_value) + + +def _handle_provider_field( + working_model: BaseModel, field_name: str, field_display: str, current_value: Any +) -> None: + """Handle the 'provider' field with a list of registered providers.""" + provider_names = sorted(_get_provider_names().keys()) + choices = ["auto"] + provider_names + default_choice = str(current_value) if current_value else "auto" + new_value = _select_with_back(field_display, choices, default=default_choice) + if new_value is _BACK_PRESSED: + return + if new_value is not None: + setattr(working_model, field_name, new_value) + + +def _handle_fallback_models_field( + working_model: BaseModel, field_name: str, field_display: str, current_value: Any +) -> None: + """Handle the 'fallback_models' field with preset-aware list management.""" + from nanobot.config.schema import InlineFallbackConfig + + items: list[Any] = list(current_value) if isinstance(current_value, list) else [] + preset_names = sorted(_MODEL_PRESET_CACHE) + + while True: + console.clear() + console.print(f"[bold]{field_display}[/bold]") + if items: + for idx, item in enumerate(items, 1): + if isinstance(item, InlineFallbackConfig): + console.print(f" {idx}. {item.model} ({item.provider}) [inline]") + else: + console.print(f" {idx}. {item}") + else: + console.print(" [dim](empty)[/dim]") + console.print() + + choices = ["[+] Add preset"] + if items: + choices.append("[-] Remove last") + choices.append("[X] Clear all") + choices.append("[Done]") + choices.append("<- Back") + + answer = _get_questionary().select( + "Manage fallback models:", + choices=choices, + qmark=">", + ).ask() + + if answer is None or answer == "<- Back": + return + if answer == "[Done]": + setattr(working_model, field_name, items) + return + if answer == "[+] Add preset": + if not preset_names: + console.print("[yellow]! No presets defined yet.[/yellow]") + _get_questionary().press_any_key_to_continue().ask() + continue + add_choices = [p for p in preset_names if p not in items] + if not add_choices: + console.print("[yellow]! All presets already added.[/yellow]") + _get_questionary().press_any_key_to_continue().ask() + continue + picked = _select_with_back("Select preset:", add_choices) + if picked is _BACK_PRESSED or picked is None: + continue + items.append(picked) + elif answer == "[-] Remove last" and items: + items.pop() + elif answer == "[X] Clear all" and items: + items.clear() + + _FIELD_HANDLERS: dict[str, Any] = { "model": _handle_model_field, "context_window_tokens": _handle_context_window_field, + "model_preset": _handle_model_preset_field, + "provider": _handle_provider_field, + "fallback_models": _handle_fallback_models_field, } @@ -757,6 +854,116 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]") +# --- Model Preset Configuration --- + + +def _sync_preset_cache(config: Config) -> None: + """Synchronise the module-level preset name cache from config.""" + _MODEL_PRESET_CACHE.clear() + _MODEL_PRESET_CACHE.update(config.model_presets.keys()) + + +def _configure_model_presets(config: Config) -> None: + """Configure model presets (CRUD).""" + _sync_preset_cache(config) + + def get_preset_choices() -> list[str]: + choices: list[str] = [] + for name, preset in config.model_presets.items(): + choices.append(f"{name} ({preset.model})") + choices.append("[+] Add new preset") + choices.append("<- Back") + return choices + + last_preset_name: str | None = None + while True: + try: + console.clear() + _show_section_header( + "Model Presets", + "Create, edit or delete named model presets for quick switching", + ) + choices = get_preset_choices() + default_choice = None + if last_preset_name: + for c in choices: + if c.startswith(last_preset_name + " ("): + default_choice = c + break + answer = _select_with_back( + "Select preset:", choices, default=default_choice + ) + + if answer is _BACK_PRESSED or answer is None or answer == "<- Back": + break + + assert isinstance(answer, str) + + if answer == "[+] Add new preset": + name_input = _get_questionary().text( + "Preset name:", + validate=lambda t: True if t and t.strip() else "Name cannot be empty", + ).ask() + if not name_input: + continue + name = name_input.strip() + if name in config.model_presets: + console.print(f"[yellow]! Preset '{name}' already exists[/yellow]") + _pause() + continue + if name == "default": + console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]") + _pause() + continue + new_preset = ModelPresetConfig(model="") + updated = _configure_pydantic_model(new_preset, f"New Preset: {name}") + if updated is not None: + config.model_presets[name] = updated + _sync_preset_cache(config) + last_preset_name = name + continue + + # Editing / deleting an existing preset + preset_name = answer.split(" (", 1)[0] + preset = config.model_presets.get(preset_name) + if preset is None: + continue + + last_preset_name = preset_name + + choices = ["Edit", "Cancel"] + if preset_name != "default": + choices.insert(1, "Delete") + action = _select_with_back( + f"Preset: {preset_name}", + choices, + default="Edit", + ) + if action is _BACK_PRESSED or action == "Cancel" or action is None: + continue + + if action == "Delete": + confirm = _get_questionary().confirm( + f"Delete preset '{preset_name}'?", + default=False, + ).ask() + if confirm: + del config.model_presets[preset_name] + _sync_preset_cache(config) + last_preset_name = None + continue + + if action == "Edit": + updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}") + if updated is not None: + config.model_presets[preset_name] = updated + _sync_preset_cache(config) + + except KeyboardInterrupt: + console.print("\n[dim]Returning to main menu...[/dim]") + break + + # --- Provider Configuration --- @@ -1043,6 +1250,12 @@ def _show_summary(config: Config) -> None: channel_rows.append((display, status)) _print_summary_panel(channel_rows, "Chat Channels") + # Model Presets + preset_rows = [] + for name, preset in config.model_presets.items(): + preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})")) + _print_summary_panel(preset_rows, "Model Presets") + # Settings sections for title, model in [ ("Agent Settings", config.agents.defaults), @@ -1112,6 +1325,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: original_config = base_config.model_copy(deep=True) config = base_config.model_copy(deep=True) + _sync_preset_cache(config) last_main_choice: str | None = None while True: @@ -1123,6 +1337,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: "What would you like to configure?", choices=[ "[P] LLM Provider", + "[M] Model Presets", "[C] Chat Channel", "[H] Channel Common", "[A] Agent Settings", @@ -1149,6 +1364,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: _menu_dispatch = { "[P] LLM Provider": lambda: _configure_providers(config), + "[M] Model Presets": lambda: _configure_model_presets(config), "[C] Chat Channel": lambda: _configure_channels(config), "[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"), "[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"), diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index f192cacee..11a284bb5 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -1074,3 +1074,242 @@ class TestConfigurePydanticModelEmptyString: result = _configure_pydantic_model(model, "Test") assert result is not None assert result.api_key == "" + + +class TestModelPresetWizard: + """Tests for model preset CRUD in the onboard wizard.""" + + def test_sync_preset_cache(self): + """_sync_preset_cache should populate the module-level cache.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _sync_preset_cache + from nanobot.config.schema import ModelPresetConfig + + config = Config() + config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini") + config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1") + _sync_preset_cache(config) + assert _MODEL_PRESET_CACHE == {"fast", "power"} + _MODEL_PRESET_CACHE.clear() + + def test_model_preset_add(self, monkeypatch): + """_configure_model_presets should add a new preset.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets + from nanobot.config.schema import ModelPresetConfig + + config = Config() + _MODEL_PRESET_CACHE.clear() + + responses = iter([ + "[+] Add new preset", + "my-preset", + "<- Back", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_text(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_configure(*_model, **_kwargs): + return ModelPresetConfig(model="gpt-test", temperature=0.5) + + def fake_select_with_back(*_args, **_kwargs): + return next(responses) + + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) + monkeypatch.setattr( + onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text) + ) + monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure) + monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) + monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None)) + + _configure_model_presets(config) + + assert "my-preset" in config.model_presets + assert config.model_presets["my-preset"].model == "gpt-test" + assert config.model_presets["my-preset"].temperature == 0.5 + _MODEL_PRESET_CACHE.clear() + + def test_model_preset_delete(self, monkeypatch): + """_configure_model_presets should delete an existing preset.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _configure_model_presets + from nanobot.config.schema import ModelPresetConfig + + config = Config() + config.model_presets["old"] = ModelPresetConfig(model="x") + _MODEL_PRESET_CACHE.clear() + _MODEL_PRESET_CACHE.update({"old", "default"}) + + responses = iter([ + "old (x)", + "Delete", + True, + "<- Back", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_confirm(*_args, **_kwargs): + return FakePrompt(next(responses)) + + def fake_select_with_back(*_args, **_kwargs): + return next(responses) + + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) + monkeypatch.setattr( + onboard_wizard, "questionary", SimpleNamespace(select=fake_select, confirm=fake_confirm) + ) + monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) + monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None)) + + _configure_model_presets(config) + + assert "old" not in config.model_presets + assert "old" not in _MODEL_PRESET_CACHE + _MODEL_PRESET_CACHE.clear() + + def test_model_preset_field_handler(self, monkeypatch): + """_handle_model_preset_field should set a preset name from choices.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field + from nanobot.config.schema import AgentDefaults + + _MODEL_PRESET_CACHE.clear() + _MODEL_PRESET_CACHE.update({"fast", "power", "default"}) + + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "fast") + + defaults = AgentDefaults() + _handle_model_preset_field(defaults, "model_preset", "Model Preset", None) + assert defaults.model_preset == "fast" + _MODEL_PRESET_CACHE.clear() + + def test_model_preset_field_handler_clear(self, monkeypatch): + """_handle_model_preset_field should clear preset when (clear/unset) chosen.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field + from nanobot.config.schema import AgentDefaults + + _MODEL_PRESET_CACHE.clear() + _MODEL_PRESET_CACHE.add("fast") + + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "(clear/unset)") + + defaults = AgentDefaults(model_preset="fast") + _handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast") + assert defaults.model_preset is None + _MODEL_PRESET_CACHE.clear() + + def test_main_menu_dispatch_includes_model_presets(self): + """_configure_model_presets should be importable and callable.""" + from nanobot.cli.onboard import _configure_model_presets + + assert callable(_configure_model_presets) + + def test_run_onboard_model_presets_edit(self, monkeypatch): + """run_onboard should handle [M] Model Presets correctly.""" + from nanobot.config.schema import ModelPresetConfig + + initial_config = Config() + + responses = iter([ + "[M] Model Presets", + "[S] Save and Exit", + ]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_select(*_args, **_kwargs): + return FakePrompt(next(responses)) + + preset_mutated = {"n": 0} + + def fake_configure_model_presets(config): + preset_mutated["n"] += 1 + config.model_presets["test"] = ModelPresetConfig(model="gpt-test") + + monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets) + monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) + monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) + monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None)) + + result = run_onboard(initial_config) + assert result.should_save is True + assert preset_mutated["n"] == 1 + assert "test" in result.config.model_presets + + def test_fallback_models_field_add(self, monkeypatch): + """_handle_fallback_models_field should add a preset name.""" + from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_fallback_models_field + from nanobot.config.schema import AgentDefaults + + _MODEL_PRESET_CACHE.clear() + _MODEL_PRESET_CACHE.update({"fast", "default"}) + + select_responses = iter(["fast"]) + questionary_responses = iter(["[+] Add preset", "[Done]"]) + + class FakePrompt: + def __init__(self, response): + self.response = response + + def ask(self): + if isinstance(self.response, BaseException): + raise self.response + return self.response + + def fake_questionary_select(*_args, **_kwargs): + return FakePrompt(next(questionary_responses)) + + def fake_select_with_back(*_args, **_kwargs): + return next(select_responses) + + monkeypatch.setattr( + onboard_wizard, "questionary", + SimpleNamespace(select=fake_questionary_select, press_any_key_to_continue=lambda: FakePrompt(None)), + ) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) + monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None, print=lambda *a, **kw: None)) + + defaults = AgentDefaults() + _handle_fallback_models_field(defaults, "fallback_models", "Fallback Models", []) + assert defaults.fallback_models == ["fast"] + _MODEL_PRESET_CACHE.clear() + + def test_provider_field_handler(self, monkeypatch): + """_handle_provider_field should set provider from choices.""" + from nanobot.cli.onboard import _handle_provider_field + from nanobot.config.schema import AgentDefaults + + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic") + + defaults = AgentDefaults() + _handle_provider_field(defaults, "provider", "Provider", "auto") + assert defaults.provider == "anthropic"