From e5294002ed3c3a3fc75f4ab3a94db0b1448aa6d7 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 21 Jun 2026 23:48:44 +0800 Subject: [PATCH] fix: broaden quick start provider setup Drive Quick Start provider choices from the provider registry instead of a short allowlist. Clean up parenthetical wizard labels and keep the beginner docs in sync. --- docs/start-without-technical-background.md | 20 +-- nanobot/cli/onboard.py | 165 ++++++++++++++------- tests/agent/test_onboard_logic.py | 114 +++++++++++--- 3 files changed, 213 insertions(+), 86 deletions(-) diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 888dc629..0d34641e 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -156,7 +156,7 @@ You will see a menu like this: ```text > What would you like to do? - [Q] Quick Start (provider + key + model) + [Q] Quick Start [A] Advanced Settings [X] Exit ``` @@ -166,24 +166,24 @@ Move through the wizard like this: | When you see | Do this | |---|---| | A menu | Use the arrow keys to highlight an option, then press `Enter`. | -| The provider menu | Choose the company or service that issued your API key. | -| The API key field | Paste the key, then press `Enter`. | -| A base URL field for `Other OpenAI-compatible` | Paste the provider base URL from its docs, then press `Enter`. | +| The provider menu | Choose the company or service you want to use. | +| An API key field | Paste the key, then press `Enter`. | +| A provider base URL field | Paste the provider base URL from its docs, then press `Enter`. | | The Model ID field | Paste a model name from your provider, then press `Enter`. | | A back option in Advanced Settings | Choose it to return to the previous menu. | -For the first setup, choose `[Q] Quick Start (provider + key + model)`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a provider that is not in the Quick Start menu, a chat app, or a tool setup. +For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields. -1. Choose `[Q] Quick Start (provider + key + model)`. -2. Choose the provider that issued your API key. -3. Paste your API key. -4. If you chose `Other OpenAI-compatible`, paste the provider base URL from that provider's docs. +1. Choose `[Q] Quick Start`. +2. Choose the provider you want to use. +3. Paste your API key if the wizard asks for one. +4. Paste the provider base URL if the wizard asks for one. 5. Paste a model ID that provider can run. 6. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. The recommended path enables the local WebUI and default AI settings. You do not need to choose a chat channel for the first run. -If you already know that you need a provider or endpoint that is not in the Quick Start menu, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`. +If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`. The wizard creates or updates: diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 9a1f65da..1c4e78dd 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -35,6 +35,17 @@ class OnboardResult: config: Config should_save: bool + +class _QuickStartProviderInfo(NamedTuple): + """Provider metadata used by the Quick Start flow.""" + + display_name: str + is_local: bool + default_api_base: str + backend: str + is_direct: bool + + # --- Field Hints for Select Fields --- # Maps field names to (choices, hint_text) # To add a new select field with hints, add an entry: @@ -54,19 +65,10 @@ _BACK_PRESSED = object() # Sentinel value for back navigation # offer existing presets as choices (e.g. AgentDefaults.model_preset). _MODEL_PRESET_CACHE: set[str] = set() -_QUICK_START_PROVIDER_KEYS = ( - "dashscope", - "deepseek", - "gemini", - "moonshot", - "openai", - "openrouter", - "siliconflow", - "zhipu", -) _QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible" -_QUICK_START_MENU_CHOICE = "[Q] Quick Start (provider + key + model)" +_CLEAR_CHOICE = "Clear value" +_QUICK_START_MENU_CHOICE = "[Q] Quick Start" _QUICK_START_STEPS = ("Provider + model", "WebUI", "Review") # Low-contrast terminal palette inspired by JetBrains Darcula/Islands. @@ -242,8 +244,8 @@ def _get_field_display_name(field_key: str, field_info) -> str: return field_info.description name = field_key suffix_map = { - "_s": " (seconds)", - "_ms": " (ms)", + "_s": " seconds", + "_ms": " ms", "_url": " URL", "_path": " Path", "_id": " ID", @@ -350,7 +352,7 @@ def _validate_field_constraint(value: Any, field_info) -> str | None: def _get_constraint_hint(field_info) -> str: """Derive a human-readable constraint hint from field metadata. - Returns a string like "(0-10)" or "(>= 0)" to append to field display names. + Returns a string like " - 0-10" or " - >= 0" to append to field display names. """ if field_info is None or not hasattr(field_info, "metadata"): return "" @@ -364,11 +366,11 @@ def _get_constraint_hint(field_info) -> str: le_val = m.le if ge_val is not None and le_val is not None: - return f" ({ge_val}-{le_val})" + return f" - {ge_val}-{le_val}" if ge_val is not None: - return f" (>= {ge_val})" + return f" - >= {ge_val}" if le_val is not None: - return f" (<= {le_val})" + return f" - <= {le_val}" return "" @@ -398,9 +400,9 @@ def _show_main_menu_header() -> None: body = Table.grid(expand=True) body.add_column(ratio=1) body.add_row(f"{__logo__} [bold {_UI_TEXT}]nanobot[/] [{_UI_MUTED}]v{__version__}[/]") - body.add_row(f"[{_UI_ACCENT}]Quick Start asks for the provider, API key, and model.[/]") + body.add_row(f"[{_UI_ACCENT}]Quick Start asks for the provider, credentials, and model.[/]") body.add_row( - f"[{_UI_MUTED}]Use Advanced later for other providers or chat apps.[/]" + f"[{_UI_MUTED}]Use Advanced later for chat apps, tools, or provider-specific details.[/]" ) console.print( Panel( @@ -639,12 +641,12 @@ def _handle_model_preset_field( ) -> 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)" + choices = [_CLEAR_CHOICE] + preset_names + default_choice = str(current_value) if current_value else _CLEAR_CHOICE new_value = _select_with_back(field_display, choices, default=default_choice) if new_value is _BACK_PRESSED: return - if new_value == "(clear/unset)": + if new_value == _CLEAR_CHOICE: setattr(working_model, field_name, None) elif new_value is not None: setattr(working_model, field_name, new_value) @@ -679,11 +681,11 @@ def _handle_fallback_models_field( if items: for idx, item in enumerate(items, 1): if isinstance(item, InlineFallbackConfig): - console.print(f" {idx}. {item.model} ({item.provider}) [inline]") + console.print(f" {idx}. {item.model} - {item.provider} inline") else: console.print(f" {idx}. {item}") else: - console.print(" [dim](empty)[/dim]") + console.print(" [dim]empty[/dim]") console.print() choices = ["[+] Add preset"] @@ -830,14 +832,14 @@ def _configure_pydantic_model( # Select fields with hints (e.g. reasoning_effort) if field_name in _SELECT_FIELD_HINTS: choices_list, hint = _SELECT_FIELD_HINTS[field_name] - select_choices = choices_list + ["(clear/unset)"] + select_choices = choices_list + [_CLEAR_CHOICE] console.print(f"[dim] Hint: {hint}[/dim]") new_value = _select_with_back( field_display, select_choices, default=current_value or select_choices[0] ) if new_value is _BACK_PRESSED: continue - if new_value == "(clear/unset)": + if new_value == _CLEAR_CHOICE: setattr(working_model, field_name, None) elif new_value is not None: setattr(working_model, field_name, new_value) @@ -898,7 +900,7 @@ def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None f"{format_token_count(context_limit)} tokens[/]" ) else: - console.print("[dim](i) Could not auto-fill context window (model not in database)[/dim]") + console.print("[dim]Could not auto-fill context window - model not in database[/dim]") # --- Model Preset Configuration --- @@ -914,13 +916,16 @@ def _configure_model_presets(config: Config) -> None: """Configure model presets (CRUD).""" _sync_preset_cache(config) - def get_preset_choices() -> list[str]: + def get_preset_choices() -> tuple[list[str], dict[str, str]]: choices: list[str] = [] + choice_to_preset: dict[str, str] = {} for name, preset in config.model_presets.items(): - choices.append(f"{name} ({preset.model})") + choice = f"{name} - {preset.model}" + choices.append(choice) + choice_to_preset[choice] = name choices.append("[+] Add new preset") choices.append("<- Back") - return choices + return choices, choice_to_preset last_preset_name: str | None = None while True: @@ -930,12 +935,12 @@ def _configure_model_presets(config: Config) -> None: "Model Presets", "Create, edit or delete named model presets for quick switching", ) - choices = get_preset_choices() + choices, choice_to_preset = get_preset_choices() default_choice = None if last_preset_name: - for c in choices: - if c.startswith(last_preset_name + " ("): - default_choice = c + for choice, name in choice_to_preset.items(): + if name == last_preset_name: + default_choice = choice break answer = _select_with_back( "Select preset:", choices, default=default_choice @@ -959,7 +964,9 @@ def _configure_model_presets(config: Config) -> None: _pause() continue if name == "default": - console.print("[yellow]! 'default' is reserved (auto-generated from Agent Settings)[/yellow]") + console.print( + "[yellow]! 'default' is reserved; it is generated from Agent Settings[/yellow]" + ) _pause() continue new_preset = ModelPresetConfig(model="") @@ -971,7 +978,9 @@ def _configure_model_presets(config: Config) -> None: continue # Editing / deleting an existing preset - preset_name = answer.split(" (", 1)[0] + preset_name = choice_to_preset.get(answer) + if preset_name is None: + continue preset = config.model_presets.get(preset_name) if preset is None: continue @@ -1371,7 +1380,7 @@ def _show_summary(config: Config) -> None: # Model Presets preset_rows = [] for name, preset in config.model_presets.items(): - preset_rows.append((name, f"{preset.model} (ctx={preset.context_window_tokens})")) + preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}")) _print_summary_panel(preset_rows, "Model Presets") # Settings sections @@ -1420,52 +1429,90 @@ def _show_quick_start_progress(active_step: int) -> None: console.print() +@lru_cache(maxsize=1) +def _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]: + """Return chat-capable providers supported by Quick Start.""" + from nanobot.providers.registry import PROVIDERS + + result: dict[str, _QuickStartProviderInfo] = {} + for spec in PROVIDERS: + if spec.name == "custom" or spec.is_oauth or spec.is_transcription_only: + continue + result[spec.name] = _QuickStartProviderInfo( + display_name=spec.display_name or spec.name, + is_local=spec.is_local, + default_api_base=spec.default_api_base, + backend=spec.backend, + is_direct=spec.is_direct, + ) + return result + + def _get_quick_start_provider_choices() -> dict[str, str]: """Return Quick Start provider display choices.""" - names = _get_provider_names() choices = { - names.get(provider_name, provider_name): provider_name - for provider_name in _QUICK_START_PROVIDER_KEYS - if provider_name in names + info.display_name: provider_name + for provider_name, info in _get_quick_start_provider_info().items() } choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = "custom" return choices +def _quick_start_requires_api_key(provider_name: str, info: _QuickStartProviderInfo | None) -> bool: + """Return whether Quick Start should ask for an API key.""" + return provider_name == "custom" or not (info and info.is_local) + + +def _quick_start_requires_base_url(provider_name: str, info: _QuickStartProviderInfo | None) -> bool: + """Return whether Quick Start must ask for a provider base URL.""" + if provider_name == "custom": + return True + if info is None or info.default_api_base: + return False + return info.backend == "azure_openai" or ( + info.backend == "openai_compat" and (info.is_direct or info.is_local) + ) + + def _configure_quick_start_provider(config: Config) -> bool: - """Configure the beginner path from provider + API key.""" + """Configure the beginner path from provider credentials and model.""" _show_quick_start_progress(1) provider_choices = _get_quick_start_provider_choices() answer = _select_with_back( - "Which provider owns this API key?", + "Which provider do you want to use?", list(provider_choices) + ["<- Back"], ) if answer is _BACK_PRESSED or answer is None or answer == "<- Back": return False assert isinstance(answer, str) provider_name = provider_choices[answer] + provider_info = _get_quick_start_provider_info().get(provider_name) - api_key = _input_text(f"{answer} API key", "", "str") - if api_key is None: - return False - api_key = api_key.strip() - if not api_key: - console.print("[yellow]! API key is required for Quick Start[/yellow]") - return False + api_key: str | None = None + if _quick_start_requires_api_key(provider_name, provider_info): + api_key = _input_text(f"{answer} API key", "", "str") + if api_key is None: + return False + api_key = api_key.strip() + if not api_key: + console.print("[yellow]! API key is required for Quick Start[/yellow]") + return False - api_base = _get_provider_info().get(provider_name, ("", False, False, ""))[3] - if provider_name == "custom": + api_base = provider_info.default_api_base if provider_info else "" + base_was_prompted = False + if _quick_start_requires_base_url(provider_name, provider_info): base_answer = _input_text( "Provider base URL", - "", + api_base, "str", ) if base_answer is None: return False + base_was_prompted = True api_base = base_answer.strip().rstrip("/") if not api_base: - console.print("[yellow]! Provider base URL is required for custom providers[/yellow]") + console.print("[yellow]! Provider base URL is required for this provider[/yellow]") return False provider_config = getattr(config.providers, provider_name, None) @@ -1479,9 +1526,13 @@ def _configure_quick_start_provider(config: Config) -> bool: console.print("[yellow]! Model ID is required for Quick Start[/yellow]") return False - provider_config.api_key = api_key - if api_base and not provider_config.api_base: - provider_config.api_base = api_base + if api_key is not None: + provider_config.api_key = api_key + if api_base: + if base_was_prompted: + provider_config.api_base = api_base + elif not provider_config.api_base: + provider_config.api_base = api_base _set_primary_quick_start_preset( config, diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 11f1eb1a..eeb79882 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -270,14 +270,16 @@ class TestGetFieldDisplayName: def test_adds_seconds_suffix(self): field_info = SimpleNamespace(description=None) name = _get_field_display_name("timeout_s", field_info) - # Contains "(Seconds)" with title case - assert "(Seconds)" in name or "(seconds)" in name + assert "Seconds" in name or "seconds" in name + assert "(" not in name + assert ")" not in name def test_adds_ms_suffix(self): field_info = SimpleNamespace(description=None) name = _get_field_display_name("delay_ms", field_info) - # Contains "(Ms)" or "(ms)" - assert "(Ms)" in name or "(ms)" in name + assert "Ms" in name or "ms" in name + assert "(" not in name + assert ")" not in name class TestFormatValue: @@ -692,7 +694,7 @@ class TestGetConstraintHint: assert _get_constraint_hint(field_info) == "" def test_ge_le_range(self): - """Field with ge+le should show '(min-max)'.""" + """Field with ge+le should show a min-max suffix.""" from pydantic import BaseModel, Field class M(BaseModel): @@ -702,9 +704,11 @@ class TestGetConstraintHint: hint = _get_constraint_hint(field_info) assert "0" in hint assert "10" in hint + assert "(" not in hint + assert ")" not in hint def test_ge_only(self): - """Field with only ge should show '(>= N)'.""" + """Field with only ge should show a >= suffix.""" from pydantic import BaseModel, Field class M(BaseModel): @@ -714,9 +718,11 @@ class TestGetConstraintHint: hint = _get_constraint_hint(field_info) assert "0" in hint assert ">=" in hint + assert "(" not in hint + assert ")" not in hint def test_le_only(self): - """Field with only le should show '(<= N)'.""" + """Field with only le should show a <= suffix.""" from pydantic import BaseModel, Field class M(BaseModel): @@ -726,15 +732,19 @@ class TestGetConstraintHint: hint = _get_constraint_hint(field_info) assert "100" in hint assert "<=" in hint + assert "(" not in hint + assert ")" not in hint def test_real_send_max_retries_hint(self): - """Actual ChannelsConfig.send_max_retries should show '(0-10)'.""" + """Actual ChannelsConfig.send_max_retries should show a 0-10 suffix.""" from nanobot.config.schema import ChannelsConfig field_info = ChannelsConfig.model_fields["send_max_retries"] hint = _get_constraint_hint(field_info) assert "0" in hint assert "10" in hint + assert "(" not in hint + assert ")" not in hint class TestInputTextWithValidation: @@ -866,7 +876,7 @@ class TestMainMenuUpdate: dirty_choices = _get_main_menu_choices(True) assert clean_choices == [ - "[Q] Quick Start (provider + key + model)", + "[Q] Quick Start", "[A] Advanced Settings", "[X] Exit", ] @@ -880,7 +890,7 @@ class TestMainMenuUpdate: initial_config = Config() responses = iter([ - "[Q] Quick Start (provider + key + model)", + "[Q] Quick Start", ]) class FakePrompt: @@ -906,8 +916,25 @@ class TestMainMenuUpdate: assert result.should_save is True assert result.config.agents.defaults.bot_name == "quickbot" + def test_quick_start_provider_choices_include_all_chat_providers(self): + """Quick Start should be driven by the provider registry, not a short allowlist.""" + from nanobot.providers.registry import PROVIDERS + + choices = onboard_wizard._get_quick_start_provider_choices() + selected_provider_names = set(choices.values()) + expected_provider_names = { + spec.name + for spec in PROVIDERS + if spec.name != "custom" and not spec.is_oauth and not spec.is_transcription_only + } + expected_provider_names.add("custom") + + assert selected_provider_names == expected_provider_names + assert "assemblyai" not in selected_provider_names + assert choices[onboard_wizard._QUICK_START_CUSTOM_PROVIDER_CHOICE] == "custom" + def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch): - """The beginner path should ask for provider, API key, and model.""" + """The beginner path should ask for provider credentials and model.""" config = Config() def fail_websocket_config(*_args, **_kwargs): @@ -963,6 +990,30 @@ class TestMainMenuUpdate: assert config.model_presets["primary"].provider == "openrouter" assert config.model_presets["primary"].model == "openai/gpt-4o-mini" + def test_quick_start_local_provider_skips_api_key(self, monkeypatch): + """Local providers should only need a model when they have a default base URL.""" + config = Config() + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "Ollama") + + def fail_text_input(*_args, **_kwargs): + raise AssertionError("Ollama Quick Start should not require an API key") + + monkeypatch.setattr(onboard_wizard, "_input_text", fail_text_input) + monkeypatch.setattr( + onboard_wizard, + "_input_model_with_autocomplete", + lambda *a, **kw: "llama3.2", + ) + + assert onboard_wizard._configure_quick_start_provider(config) is True + + assert config.providers.ollama.api_key is None + assert config.providers.ollama.api_base == "http://localhost:11434/v1" + assert config.model_presets["primary"].provider == "ollama" + assert config.model_presets["primary"].model == "llama3.2" + def test_quick_start_openai_stores_key_and_model_without_base(self, monkeypatch): """OpenAI should support key-only setup without storing a default base URL.""" config = Config() @@ -1008,6 +1059,27 @@ class TestMainMenuUpdate: assert config.model_presets["primary"].provider == "custom" assert config.model_presets["primary"].model == "custom-model" + def test_quick_start_provider_without_default_base_url_prompts_for_base(self, monkeypatch): + """Providers that require an endpoint should ask for a base URL in Quick Start.""" + config = Config() + text_answers = iter(["azure-key", "https://azure.example.test/openai"]) + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "Azure OpenAI") + monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(text_answers)) + monkeypatch.setattr( + onboard_wizard, + "_input_model_with_autocomplete", + lambda *a, **kw: "deployment-name", + ) + + assert onboard_wizard._configure_quick_start_provider(config) is True + + assert config.providers.azure_openai.api_key == "azure-key" + assert config.providers.azure_openai.api_base == "https://azure.example.test/openai" + assert config.model_presets["primary"].provider == "azure_openai" + assert config.model_presets["primary"].model == "deployment-name" + def test_quick_start_requires_api_key_before_setting_defaults(self, monkeypatch): """Quick Start should not create a ready-looking config without an API key.""" config = Config() @@ -1447,12 +1519,12 @@ class TestModelPresetWizard: from nanobot.config.schema import ModelPresetConfig config = Config() - config.model_presets["old"] = ModelPresetConfig(model="x") + config.model_presets["old - preset"] = ModelPresetConfig(model="x") _MODEL_PRESET_CACHE.clear() - _MODEL_PRESET_CACHE.update({"old", "default"}) + _MODEL_PRESET_CACHE.update({"old - preset", "default"}) responses = iter([ - "old (x)", + "old - preset - x", "Delete", True, "<- Back", @@ -1485,8 +1557,8 @@ class TestModelPresetWizard: _configure_model_presets(config) - assert "old" not in config.model_presets - assert "old" not in _MODEL_PRESET_CACHE + assert "old - preset" not in config.model_presets + assert "old - preset" not in _MODEL_PRESET_CACHE _MODEL_PRESET_CACHE.clear() def test_model_preset_field_handler(self, monkeypatch): @@ -1505,14 +1577,18 @@ class TestModelPresetWizard: _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 + """_handle_model_preset_field should clear preset when Clear value is chosen.""" + from nanobot.cli.onboard import ( + _CLEAR_CHOICE, + _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)") + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: _CLEAR_CHOICE) defaults = AgentDefaults(model_preset="fast") _handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")