diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 66a73b6a..9fbb9f9b 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 (API key first) + [Q] Quick Start (provider + key) [A] Advanced Settings [X] Exit ``` @@ -166,20 +166,22 @@ 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 | Paste the provider base URL from its docs, then press `Enter`. | +| A base URL field for `Other OpenAI-compatible` | Paste the provider base URL from its docs, 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 (API key first)`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a different provider, chat app, or tool setup. +For the first setup, choose `[Q] Quick Start (provider + key)`. 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. -1. Choose `[Q] Quick Start (API key first)`. -2. Paste your API key. -3. If nanobot cannot recognize the key, paste the provider base URL from that provider's docs. -4. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. +1. Choose `[Q] Quick Start (provider + key)`. +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. +5. 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 another provider, choose `Advanced Settings` instead of Quick Start. [`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 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`. The wizard creates or updates: diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index b9a54eb5..5785da9d 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -54,11 +54,19 @@ _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_DEFAULT_MODELS = { - "deepseek": "deepseek-v4-flash", -} +_QUICK_START_PROVIDER_KEYS = ( + "dashscope", + "deepseek", + "gemini", + "moonshot", + "openai", + "openrouter", + "siliconflow", + "zhipu", +) +_QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible" -_QUICK_START_STEPS = ("API key", "WebUI", "Review") +_QUICK_START_STEPS = ("Provider + key", "WebUI", "Review") # Low-contrast terminal palette inspired by JetBrains Darcula/Islands. _UI_ACCENT = "#6B9BFA" @@ -389,7 +397,7 @@ 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 starts with one API key.[/]") + body.add_row(f"[{_UI_ACCENT}]Quick Start asks for the provider and API key.[/]") body.add_row( f"[{_UI_MUTED}]Use Advanced later for other providers or chat apps.[/]" ) @@ -1411,36 +1419,16 @@ def _show_quick_start_progress(active_step: int) -> None: console.print() -def _detect_quick_start_provider_from_key(api_key: str) -> str | None: - """Return a provider when the API key prefix identifies exactly one provider.""" - from nanobot.providers.registry import PROVIDERS - - matches = [ - spec.name - for spec in PROVIDERS - if spec.detect_by_key_prefix - and api_key.startswith(spec.detect_by_key_prefix) - and not spec.is_oauth - and not spec.is_transcription_only - ] - return matches[0] if len(matches) == 1 else None - - -def _detect_quick_start_provider_from_base(api_base: str) -> str | None: - """Return a provider when the user-provided base URL matches registry metadata.""" - from nanobot.providers.registry import PROVIDERS - - normalized = api_base.rstrip("/").lower() - for spec in PROVIDERS: - if spec.is_oauth or spec.is_transcription_only: - continue - default_base = spec.default_api_base.rstrip("/").lower() - if default_base and (normalized == default_base or normalized.startswith(default_base + "/")): - return spec.name - keyword = spec.detect_by_base_keyword.lower() - if keyword and keyword in normalized: - return spec.name - return None +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 + } + choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = "custom" + return choices def _models_url(api_base: str) -> str: @@ -1477,10 +1465,20 @@ def _fetch_first_quick_start_model(api_base: str, api_key: str) -> str | None: def _configure_quick_start_provider(config: Config) -> bool: - """Configure the beginner path from one API key plus base URL fallback.""" + """Configure the beginner path from provider + API key.""" _show_quick_start_progress(1) - api_key = _input_text("API key", "", "str") + provider_choices = _get_quick_start_provider_choices() + answer = _select_with_back( + "Which provider owns this API key?", + 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] + + api_key = _input_text(f"{answer} API key", "", "str") if api_key is None: return False api_key = api_key.strip() @@ -1488,16 +1486,13 @@ def _configure_quick_start_provider(config: Config) -> bool: console.print("[yellow]! API key is required for Quick Start[/yellow]") return False - provider_name = _detect_quick_start_provider_from_key(api_key) provider_info = _get_provider_info() - api_base = "" - if provider_name: - _display, _is_gateway, _is_local, api_base = provider_info.get( - provider_name, (provider_name, False, False, "") - ) - else: + _display, _is_gateway, _is_local, api_base = provider_info.get( + provider_name, (provider_name, False, False, "") + ) + if provider_name == "custom": base_answer = _input_text( - "Provider base URL (only this URL will be tested)", + "Provider base URL", "", "str", ) @@ -1505,9 +1500,8 @@ def _configure_quick_start_provider(config: Config) -> bool: return False api_base = base_answer.strip().rstrip("/") if not api_base: - console.print("[yellow]! Provider base URL is required when the key is not recognized[/yellow]") + console.print("[yellow]! Provider base URL is required for custom providers[/yellow]") return False - provider_name = _detect_quick_start_provider_from_base(api_base) or "custom" provider_config = getattr(config.providers, provider_name, None) if provider_config is None: @@ -1518,8 +1512,8 @@ def _configure_quick_start_provider(config: Config) -> bool: if api_base and not provider_config.api_base: provider_config.api_base = api_base - model = _QUICK_START_DEFAULT_MODELS.get(provider_name) - if not model and provider_config.api_base: + model = None + if provider_config.api_base: model = _fetch_first_quick_start_model(provider_config.api_base, api_key) if not model: model = _input_model_with_autocomplete("Model ID", "", provider_name) @@ -1580,11 +1574,11 @@ def _show_quick_start_summary(config: Config) -> None: def _configure_quick_start(config: Config) -> bool: - """First-run path: API key + local WebUI, with advanced settings hidden.""" + """First-run path: provider + API key + local WebUI, with advanced settings hidden.""" console.clear() _show_section_header( "Quick Start", - "Paste one API key. nanobot will use recommended local WebUI defaults.", + "Choose the API provider, paste the key, then use the local WebUI.", ) if not _configure_quick_start_provider(config): _pause() @@ -1631,7 +1625,7 @@ def _prompt_main_menu_exit(has_unsaved_changes: bool) -> str: def _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]: """Return the top-level choices, keeping save actions hidden until needed.""" choices = [ - "[Q] Quick Start (API key first)", + "[Q] Quick Start (provider + key)", "[A] Advanced Settings", ] if has_unsaved_changes: @@ -1736,7 +1730,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: return OnboardResult(config=original_config, should_save=False) continue - if answer == "[Q] Quick Start (API key first)": + if answer == "[Q] Quick Start (provider + key)": if _configure_quick_start(config): return OnboardResult(config=config, should_save=True) continue diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 8d6170eb..10f26bda 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -866,7 +866,7 @@ class TestMainMenuUpdate: dirty_choices = _get_main_menu_choices(True) assert clean_choices == [ - "[Q] Quick Start (API key first)", + "[Q] Quick Start (provider + key)", "[A] Advanced Settings", "[X] Exit", ] @@ -880,7 +880,7 @@ class TestMainMenuUpdate: initial_config = Config() responses = iter([ - "[Q] Quick Start (API key first)", + "[Q] Quick Start (provider + key)", ]) class FakePrompt: @@ -906,13 +906,12 @@ class TestMainMenuUpdate: assert result.should_save is True assert result.config.agents.defaults.bot_name == "quickbot" - def test_quick_start_base_url_fallback_skips_advanced_prompts(self, monkeypatch): - """The beginner path should ask for a base URL only when the key is not recognized.""" + def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch): + """The beginner path should ask for provider and API key without advanced settings.""" config = Config() - text_answers = iter(["sk-ds-test", "https://api.deepseek.com"]) def fail_model_input(*_args, **_kwargs): - raise AssertionError("Quick Start should not ask for a model ID when defaults are known") + raise AssertionError("Quick Start should not ask for a model ID when /models works") def fail_websocket_config(*_args, **_kwargs): raise AssertionError("Quick Start should not open WebSocket settings") @@ -921,7 +920,9 @@ class TestMainMenuUpdate: monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None) monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) - monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(text_answers)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "DeepSeek") + monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: "sk-ds-test") + monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", lambda *a, **kw: "deepseek-v4-flash") monkeypatch.setattr(onboard_wizard, "_input_model_with_autocomplete", fail_model_input) monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fail_websocket_config) monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None) @@ -934,27 +935,33 @@ class TestMainMenuUpdate: assert config.providers.deepseek.api_base == "https://api.deepseek.com" assert config.agents.defaults.model_preset == "primary" assert config.model_presets["primary"].provider == "deepseek" - assert config.model_presets["primary"].model == onboard_wizard._QUICK_START_DEFAULT_MODELS["deepseek"] + assert config.model_presets["primary"].model == "deepseek-v4-flash" websocket = getattr(config.channels, "websocket") assert websocket["enabled"] is True assert websocket["websocketRequiresToken"] is True - def test_quick_start_detects_provider_from_key_prefix(self, monkeypatch): - """Unique key prefixes should identify the provider without asking for base URL.""" + def test_quick_start_provider_choice_fetches_models_from_selected_provider(self, monkeypatch): + """Known providers should fetch models only from the selected provider base URL.""" config = Config() - prompts: list[str] = [] - - def fake_input_text(prompt, *_args, **_kwargs): - prompts.append(prompt) - return "sk-or-test" + calls: dict[str, str] = {} monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) - monkeypatch.setattr(onboard_wizard, "_input_text", fake_input_text) - monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", lambda *a, **kw: "openai/gpt-4o-mini") + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "OpenRouter") + monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: "sk-or-test") + + def fake_fetch(api_base, api_key): + calls["api_base"] = api_base + calls["api_key"] = api_key + return "openai/gpt-4o-mini" + + monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", fake_fetch) assert onboard_wizard._configure_quick_start_provider(config) is True - assert prompts == ["API key"] + assert calls == { + "api_base": "https://openrouter.ai/api/v1", + "api_key": "sk-or-test", + } assert config.providers.openrouter.api_key == "sk-or-test" assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1" assert config.model_presets["primary"].provider == "openrouter" @@ -972,6 +979,11 @@ class TestMainMenuUpdate: return "custom-model" monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_select_with_back", + lambda *a, **kw: onboard_wizard._QUICK_START_CUSTOM_PROVIDER_CHOICE, + ) monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(text_answers)) monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", fake_fetch) @@ -988,6 +1000,7 @@ class TestMainMenuUpdate: config = Config() monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "DeepSeek") monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: "") assert onboard_wizard._configure_quick_start_provider(config) is False @@ -1002,7 +1015,7 @@ class TestMainMenuUpdate: """Quick Start summary should not tell users to run gateway before adding a key.""" config = Config() config.model_presets["primary"] = ModelPresetConfig( - model=onboard_wizard._QUICK_START_DEFAULT_MODELS["deepseek"], + model="deepseek-v4-flash", provider="deepseek", )