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
> 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:
+47 -53
View File
@@ -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
+32 -19
View File
@@ -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",
)