fix: ask provider in quick start

Maintainer edit: replace Quick Start key/base detection with an explicit provider-first flow. Users choose the provider that issued the API key, paste the key, and only custom OpenAI-compatible setups ask for a base URL.
This commit is contained in:
chengyongru
2026-06-22 13:04:05 +08:00
committed by Xubin Ren
parent 2319b660e6
commit 71631f9ab0
3 changed files with 89 additions and 80 deletions
+10 -8
View File
@@ -156,7 +156,7 @@ You will see a menu like this:
```text ```text
> What would you like to do? > What would you like to do?
[Q] Quick Start (API key first) [Q] Quick Start (provider + key)
[A] Advanced Settings [A] Advanced Settings
[X] Exit [X] Exit
``` ```
@@ -166,20 +166,22 @@ Move through the wizard like this:
| When you see | Do this | | When you see | Do this |
|---|---| |---|---|
| A menu | Use the arrow keys to highlight an option, then press `Enter`. | | 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`. | | 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. | | 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)`. 1. Choose `[Q] Quick Start (provider + key)`.
2. Paste your API key. 2. Choose the provider that issued your API key.
3. If nanobot cannot recognize the key, paste the provider base URL from that provider's docs. 3. Paste your API key.
4. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. 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. 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: The wizard creates or updates:
+44 -50
View File
@@ -54,11 +54,19 @@ _BACK_PRESSED = object() # Sentinel value for back navigation
# offer existing presets as choices (e.g. AgentDefaults.model_preset). # offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set() _MODEL_PRESET_CACHE: set[str] = set()
_QUICK_START_DEFAULT_MODELS = { _QUICK_START_PROVIDER_KEYS = (
"deepseek": "deepseek-v4-flash", "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. # Low-contrast terminal palette inspired by JetBrains Darcula/Islands.
_UI_ACCENT = "#6B9BFA" _UI_ACCENT = "#6B9BFA"
@@ -389,7 +397,7 @@ def _show_main_menu_header() -> None:
body = Table.grid(expand=True) body = Table.grid(expand=True)
body.add_column(ratio=1) body.add_column(ratio=1)
body.add_row(f"{__logo__} [bold {_UI_TEXT}]nanobot[/] [{_UI_MUTED}]v{__version__}[/]") 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( body.add_row(
f"[{_UI_MUTED}]Use Advanced later for other providers or chat apps.[/]" 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() console.print()
def _detect_quick_start_provider_from_key(api_key: str) -> str | None: def _get_quick_start_provider_choices() -> dict[str, str]:
"""Return a provider when the API key prefix identifies exactly one provider.""" """Return Quick Start provider display choices."""
from nanobot.providers.registry import PROVIDERS names = _get_provider_names()
choices = {
matches = [ names.get(provider_name, provider_name): provider_name
spec.name for provider_name in _QUICK_START_PROVIDER_KEYS
for spec in PROVIDERS if provider_name in names
if spec.detect_by_key_prefix }
and api_key.startswith(spec.detect_by_key_prefix) choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = "custom"
and not spec.is_oauth return choices
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 _models_url(api_base: str) -> str: 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: 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) _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: if api_key is None:
return False return False
api_key = api_key.strip() 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]") console.print("[yellow]! API key is required for Quick Start[/yellow]")
return False return False
provider_name = _detect_quick_start_provider_from_key(api_key)
provider_info = _get_provider_info() provider_info = _get_provider_info()
api_base = ""
if provider_name:
_display, _is_gateway, _is_local, api_base = provider_info.get( _display, _is_gateway, _is_local, api_base = provider_info.get(
provider_name, (provider_name, False, False, "") provider_name, (provider_name, False, False, "")
) )
else: if provider_name == "custom":
base_answer = _input_text( base_answer = _input_text(
"Provider base URL (only this URL will be tested)", "Provider base URL",
"", "",
"str", "str",
) )
@@ -1505,9 +1500,8 @@ def _configure_quick_start_provider(config: Config) -> bool:
return False return False
api_base = base_answer.strip().rstrip("/") api_base = base_answer.strip().rstrip("/")
if not api_base: 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 return False
provider_name = _detect_quick_start_provider_from_base(api_base) or "custom"
provider_config = getattr(config.providers, provider_name, None) provider_config = getattr(config.providers, provider_name, None)
if provider_config is 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: if api_base and not provider_config.api_base:
provider_config.api_base = api_base provider_config.api_base = api_base
model = _QUICK_START_DEFAULT_MODELS.get(provider_name) model = None
if not model and provider_config.api_base: if provider_config.api_base:
model = _fetch_first_quick_start_model(provider_config.api_base, api_key) model = _fetch_first_quick_start_model(provider_config.api_base, api_key)
if not model: if not model:
model = _input_model_with_autocomplete("Model ID", "", provider_name) 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: 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() console.clear()
_show_section_header( _show_section_header(
"Quick Start", "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): if not _configure_quick_start_provider(config):
_pause() _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]: def _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]:
"""Return the top-level choices, keeping save actions hidden until needed.""" """Return the top-level choices, keeping save actions hidden until needed."""
choices = [ choices = [
"[Q] Quick Start (API key first)", "[Q] Quick Start (provider + key)",
"[A] Advanced Settings", "[A] Advanced Settings",
] ]
if has_unsaved_changes: 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) return OnboardResult(config=original_config, should_save=False)
continue continue
if answer == "[Q] Quick Start (API key first)": if answer == "[Q] Quick Start (provider + key)":
if _configure_quick_start(config): if _configure_quick_start(config):
return OnboardResult(config=config, should_save=True) return OnboardResult(config=config, should_save=True)
continue continue
+32 -19
View File
@@ -866,7 +866,7 @@ class TestMainMenuUpdate:
dirty_choices = _get_main_menu_choices(True) dirty_choices = _get_main_menu_choices(True)
assert clean_choices == [ assert clean_choices == [
"[Q] Quick Start (API key first)", "[Q] Quick Start (provider + key)",
"[A] Advanced Settings", "[A] Advanced Settings",
"[X] Exit", "[X] Exit",
] ]
@@ -880,7 +880,7 @@ class TestMainMenuUpdate:
initial_config = Config() initial_config = Config()
responses = iter([ responses = iter([
"[Q] Quick Start (API key first)", "[Q] Quick Start (provider + key)",
]) ])
class FakePrompt: class FakePrompt:
@@ -906,13 +906,12 @@ class TestMainMenuUpdate:
assert result.should_save is True assert result.should_save is True
assert result.config.agents.defaults.bot_name == "quickbot" assert result.config.agents.defaults.bot_name == "quickbot"
def test_quick_start_base_url_fallback_skips_advanced_prompts(self, monkeypatch): def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch):
"""The beginner path should ask for a base URL only when the key is not recognized.""" """The beginner path should ask for provider and API key without advanced settings."""
config = Config() config = Config()
text_answers = iter(["sk-ds-test", "https://api.deepseek.com"])
def fail_model_input(*_args, **_kwargs): 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): def fail_websocket_config(*_args, **_kwargs):
raise AssertionError("Quick Start should not open WebSocket settings") 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.console, "clear", lambda: None)
monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: 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, "_input_model_with_autocomplete", fail_model_input)
monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fail_websocket_config) monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fail_websocket_config)
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None) 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.providers.deepseek.api_base == "https://api.deepseek.com"
assert config.agents.defaults.model_preset == "primary" assert config.agents.defaults.model_preset == "primary"
assert config.model_presets["primary"].provider == "deepseek" 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") websocket = getattr(config.channels, "websocket")
assert websocket["enabled"] is True assert websocket["enabled"] is True
assert websocket["websocketRequiresToken"] is True assert websocket["websocketRequiresToken"] is True
def test_quick_start_detects_provider_from_key_prefix(self, monkeypatch): def test_quick_start_provider_choice_fetches_models_from_selected_provider(self, monkeypatch):
"""Unique key prefixes should identify the provider without asking for base URL.""" """Known providers should fetch models only from the selected provider base URL."""
config = Config() config = Config()
prompts: list[str] = [] calls: dict[str, str] = {}
def fake_input_text(prompt, *_args, **_kwargs):
prompts.append(prompt)
return "sk-or-test"
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None)
monkeypatch.setattr(onboard_wizard, "_input_text", fake_input_text) monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "OpenRouter")
monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", lambda *a, **kw: "openai/gpt-4o-mini") 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 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_key == "sk-or-test"
assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1" assert config.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
assert config.model_presets["primary"].provider == "openrouter" assert config.model_presets["primary"].provider == "openrouter"
@@ -972,6 +979,11 @@ class TestMainMenuUpdate:
return "custom-model" return "custom-model"
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) 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, "_input_text", lambda *a, **kw: next(text_answers))
monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", fake_fetch) monkeypatch.setattr(onboard_wizard, "_fetch_first_quick_start_model", fake_fetch)
@@ -988,6 +1000,7 @@ class TestMainMenuUpdate:
config = Config() config = Config()
monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) 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: "") monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: "")
assert onboard_wizard._configure_quick_start_provider(config) is False 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.""" """Quick Start summary should not tell users to run gateway before adding a key."""
config = Config() config = Config()
config.model_presets["primary"] = ModelPresetConfig( config.model_presets["primary"] = ModelPresetConfig(
model=onboard_wizard._QUICK_START_DEFAULT_MODELS["deepseek"], model="deepseek-v4-flash",
provider="deepseek", provider="deepseek",
) )