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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user