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.
This commit is contained in:
chengyongru
2026-06-22 13:04:05 +08:00
committed by Xubin Ren
parent eb14720381
commit e5294002ed
3 changed files with 213 additions and 86 deletions
+10 -10
View File
@@ -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:
+99 -48
View File
@@ -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,32 +1429,68 @@ 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: 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
@@ -1454,18 +1499,20 @@ def _configure_quick_start_provider(config: Config) -> bool:
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,8 +1526,12 @@ def _configure_quick_start_provider(config: Config) -> bool:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False
if api_key is not None:
provider_config.api_key = api_key
if api_base and not provider_config.api_base:
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(
+95 -19
View File
@@ -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")