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-21 23:48:44 +08:00 committed by Xubin Ren
parent eb14720381
commit e5294002ed
3 changed files with 213 additions and 86 deletions

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 (provider + key + model) [Q] Quick Start
[A] Advanced Settings [A] Advanced Settings
[X] Exit [X] Exit
``` ```
@ -166,24 +166,24 @@ 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 provider menu | Choose the company or service you want to use. |
| The API key field | Paste the key, then press `Enter`. | | An 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`. | | 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`. | | 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. | | 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)`. 1. Choose `[Q] Quick Start`.
2. Choose the provider that issued your API key. 2. Choose the provider you want to use.
3. Paste your API key. 3. Paste your API key if the wizard asks for one.
4. If you chose `Other OpenAI-compatible`, paste the provider base URL from that provider's docs. 4. Paste the provider base URL if the wizard asks for one.
5. Paste a model ID that provider can run. 5. Paste a model ID that provider can run.
6. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes. 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. 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: The wizard creates or updates:

View File

@ -35,6 +35,17 @@ class OnboardResult:
config: Config config: Config
should_save: bool 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 --- # --- Field Hints for Select Fields ---
# Maps field names to (choices, hint_text) # Maps field names to (choices, hint_text)
# To add a new select field with hints, add an entry: # 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). # offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set() _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_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") _QUICK_START_STEPS = ("Provider + model", "WebUI", "Review")
# Low-contrast terminal palette inspired by JetBrains Darcula/Islands. # 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 return field_info.description
name = field_key name = field_key
suffix_map = { suffix_map = {
"_s": " (seconds)", "_s": " seconds",
"_ms": " (ms)", "_ms": " ms",
"_url": " URL", "_url": " URL",
"_path": " Path", "_path": " Path",
"_id": " ID", "_id": " ID",
@ -350,7 +352,7 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
def _get_constraint_hint(field_info) -> str: def _get_constraint_hint(field_info) -> str:
"""Derive a human-readable constraint hint from field metadata. """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"): if field_info is None or not hasattr(field_info, "metadata"):
return "" return ""
@ -364,11 +366,11 @@ def _get_constraint_hint(field_info) -> str:
le_val = m.le le_val = m.le
if ge_val is not None and le_val is not None: 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: if ge_val is not None:
return f" (>= {ge_val})" return f" - >= {ge_val}"
if le_val is not None: if le_val is not None:
return f" (<= {le_val})" return f" - <= {le_val}"
return "" return ""
@ -398,9 +400,9 @@ 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 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( 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( console.print(
Panel( Panel(
@ -639,12 +641,12 @@ def _handle_model_preset_field(
) -> None: ) -> None:
"""Handle the 'model_preset' field with a list of existing presets.""" """Handle the 'model_preset' field with a list of existing presets."""
preset_names = sorted(_MODEL_PRESET_CACHE) preset_names = sorted(_MODEL_PRESET_CACHE)
choices = ["(clear/unset)"] + preset_names choices = [_CLEAR_CHOICE] + preset_names
default_choice = str(current_value) if current_value else "(clear/unset)" default_choice = str(current_value) if current_value else _CLEAR_CHOICE
new_value = _select_with_back(field_display, choices, default=default_choice) new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED: if new_value is _BACK_PRESSED:
return return
if new_value == "(clear/unset)": if new_value == _CLEAR_CHOICE:
setattr(working_model, field_name, None) setattr(working_model, field_name, None)
elif new_value is not None: elif new_value is not None:
setattr(working_model, field_name, new_value) setattr(working_model, field_name, new_value)
@ -679,11 +681,11 @@ def _handle_fallback_models_field(
if items: if items:
for idx, item in enumerate(items, 1): for idx, item in enumerate(items, 1):
if isinstance(item, InlineFallbackConfig): if isinstance(item, InlineFallbackConfig):
console.print(f" {idx}. {item.model} ({item.provider}) [inline]") console.print(f" {idx}. {item.model} - {item.provider} inline")
else: else:
console.print(f" {idx}. {item}") console.print(f" {idx}. {item}")
else: else:
console.print(" [dim](empty)[/dim]") console.print(" [dim]empty[/dim]")
console.print() console.print()
choices = ["[+] Add preset"] choices = ["[+] Add preset"]
@ -830,14 +832,14 @@ def _configure_pydantic_model(
# Select fields with hints (e.g. reasoning_effort) # Select fields with hints (e.g. reasoning_effort)
if field_name in _SELECT_FIELD_HINTS: if field_name in _SELECT_FIELD_HINTS:
choices_list, hint = _SELECT_FIELD_HINTS[field_name] 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]") console.print(f"[dim] Hint: {hint}[/dim]")
new_value = _select_with_back( new_value = _select_with_back(
field_display, select_choices, default=current_value or select_choices[0] field_display, select_choices, default=current_value or select_choices[0]
) )
if new_value is _BACK_PRESSED: if new_value is _BACK_PRESSED:
continue continue
if new_value == "(clear/unset)": if new_value == _CLEAR_CHOICE:
setattr(working_model, field_name, None) setattr(working_model, field_name, None)
elif new_value is not None: elif new_value is not None:
setattr(working_model, field_name, new_value) 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[/]" f"{format_token_count(context_limit)} tokens[/]"
) )
else: 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 --- # --- Model Preset Configuration ---
@ -914,13 +916,16 @@ def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD).""" """Configure model presets (CRUD)."""
_sync_preset_cache(config) _sync_preset_cache(config)
def get_preset_choices() -> list[str]: def get_preset_choices() -> tuple[list[str], dict[str, str]]:
choices: list[str] = [] choices: list[str] = []
choice_to_preset: dict[str, str] = {}
for name, preset in config.model_presets.items(): 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("[+] Add new preset")
choices.append("<- Back") choices.append("<- Back")
return choices return choices, choice_to_preset
last_preset_name: str | None = None last_preset_name: str | None = None
while True: while True:
@ -930,12 +935,12 @@ def _configure_model_presets(config: Config) -> None:
"Model Presets", "Model Presets",
"Create, edit or delete named model presets for quick switching", "Create, edit or delete named model presets for quick switching",
) )
choices = get_preset_choices() choices, choice_to_preset = get_preset_choices()
default_choice = None default_choice = None
if last_preset_name: if last_preset_name:
for c in choices: for choice, name in choice_to_preset.items():
if c.startswith(last_preset_name + " ("): if name == last_preset_name:
default_choice = c default_choice = choice
break break
answer = _select_with_back( answer = _select_with_back(
"Select preset:", choices, default=default_choice "Select preset:", choices, default=default_choice
@ -959,7 +964,9 @@ def _configure_model_presets(config: Config) -> None:
_pause() _pause()
continue continue
if name == "default": 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() _pause()
continue continue
new_preset = ModelPresetConfig(model="") new_preset = ModelPresetConfig(model="")
@ -971,7 +978,9 @@ def _configure_model_presets(config: Config) -> None:
continue continue
# Editing / deleting an existing preset # 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) preset = config.model_presets.get(preset_name)
if preset is None: if preset is None:
continue continue
@ -1371,7 +1380,7 @@ def _show_summary(config: Config) -> None:
# Model Presets # Model Presets
preset_rows = [] preset_rows = []
for name, preset in config.model_presets.items(): 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") _print_summary_panel(preset_rows, "Model Presets")
# Settings sections # Settings sections
@ -1420,52 +1429,90 @@ def _show_quick_start_progress(active_step: int) -> None:
console.print() 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]: def _get_quick_start_provider_choices() -> dict[str, str]:
"""Return Quick Start provider display choices.""" """Return Quick Start provider display choices."""
names = _get_provider_names()
choices = { choices = {
names.get(provider_name, provider_name): provider_name info.display_name: provider_name
for provider_name in _QUICK_START_PROVIDER_KEYS for provider_name, info in _get_quick_start_provider_info().items()
if provider_name in names
} }
choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = "custom" choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = "custom"
return choices 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: 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) _show_quick_start_progress(1)
provider_choices = _get_quick_start_provider_choices() provider_choices = _get_quick_start_provider_choices()
answer = _select_with_back( answer = _select_with_back(
"Which provider owns this API key?", "Which provider do you want to use?",
list(provider_choices) + ["<- Back"], list(provider_choices) + ["<- Back"],
) )
if answer is _BACK_PRESSED or answer is None or answer == "<- Back": if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
return False return False
assert isinstance(answer, str) assert isinstance(answer, str)
provider_name = provider_choices[answer] provider_name = provider_choices[answer]
provider_info = _get_quick_start_provider_info().get(provider_name)
api_key = _input_text(f"{answer} API key", "", "str") api_key: str | None = None
if api_key is None: if _quick_start_requires_api_key(provider_name, provider_info):
return False api_key = _input_text(f"{answer} API key", "", "str")
api_key = api_key.strip() if api_key is None:
if not api_key: return False
console.print("[yellow]! API key is required for Quick Start[/yellow]") api_key = api_key.strip()
return False if not api_key:
console.print("[yellow]! API key is required for Quick Start[/yellow]")
return False
api_base = _get_provider_info().get(provider_name, ("", False, False, ""))[3] api_base = provider_info.default_api_base if provider_info else ""
if provider_name == "custom": base_was_prompted = False
if _quick_start_requires_base_url(provider_name, provider_info):
base_answer = _input_text( base_answer = _input_text(
"Provider base URL", "Provider base URL",
"", api_base,
"str", "str",
) )
if base_answer is None: if base_answer is None:
return False return False
base_was_prompted = True
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 for custom providers[/yellow]") console.print("[yellow]! Provider base URL is required for this provider[/yellow]")
return False return False
provider_config = getattr(config.providers, provider_name, None) provider_config = getattr(config.providers, provider_name, None)
@ -1479,9 +1526,13 @@ def _configure_quick_start_provider(config: Config) -> bool:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]") console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False return False
provider_config.api_key = api_key if api_key is not None:
if api_base and not provider_config.api_base: provider_config.api_key = api_key
provider_config.api_base = 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( _set_primary_quick_start_preset(
config, config,

View File

@ -270,14 +270,16 @@ class TestGetFieldDisplayName:
def test_adds_seconds_suffix(self): def test_adds_seconds_suffix(self):
field_info = SimpleNamespace(description=None) field_info = SimpleNamespace(description=None)
name = _get_field_display_name("timeout_s", field_info) 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): def test_adds_ms_suffix(self):
field_info = SimpleNamespace(description=None) field_info = SimpleNamespace(description=None)
name = _get_field_display_name("delay_ms", field_info) 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: class TestFormatValue:
@ -692,7 +694,7 @@ class TestGetConstraintHint:
assert _get_constraint_hint(field_info) == "" assert _get_constraint_hint(field_info) == ""
def test_ge_le_range(self): 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 from pydantic import BaseModel, Field
class M(BaseModel): class M(BaseModel):
@ -702,9 +704,11 @@ class TestGetConstraintHint:
hint = _get_constraint_hint(field_info) hint = _get_constraint_hint(field_info)
assert "0" in hint assert "0" in hint
assert "10" in hint assert "10" in hint
assert "(" not in hint
assert ")" not in hint
def test_ge_only(self): 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 from pydantic import BaseModel, Field
class M(BaseModel): class M(BaseModel):
@ -714,9 +718,11 @@ class TestGetConstraintHint:
hint = _get_constraint_hint(field_info) hint = _get_constraint_hint(field_info)
assert "0" in hint assert "0" in hint
assert ">=" in hint assert ">=" in hint
assert "(" not in hint
assert ")" not in hint
def test_le_only(self): 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 from pydantic import BaseModel, Field
class M(BaseModel): class M(BaseModel):
@ -726,15 +732,19 @@ class TestGetConstraintHint:
hint = _get_constraint_hint(field_info) hint = _get_constraint_hint(field_info)
assert "100" in hint assert "100" in hint
assert "<=" in hint assert "<=" in hint
assert "(" not in hint
assert ")" not in hint
def test_real_send_max_retries_hint(self): 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 from nanobot.config.schema import ChannelsConfig
field_info = ChannelsConfig.model_fields["send_max_retries"] field_info = ChannelsConfig.model_fields["send_max_retries"]
hint = _get_constraint_hint(field_info) hint = _get_constraint_hint(field_info)
assert "0" in hint assert "0" in hint
assert "10" in hint assert "10" in hint
assert "(" not in hint
assert ")" not in hint
class TestInputTextWithValidation: class TestInputTextWithValidation:
@ -866,7 +876,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 (provider + key + model)", "[Q] Quick Start",
"[A] Advanced Settings", "[A] Advanced Settings",
"[X] Exit", "[X] Exit",
] ]
@ -880,7 +890,7 @@ class TestMainMenuUpdate:
initial_config = Config() initial_config = Config()
responses = iter([ responses = iter([
"[Q] Quick Start (provider + key + model)", "[Q] Quick Start",
]) ])
class FakePrompt: class FakePrompt:
@ -906,8 +916,25 @@ 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_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): 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() config = Config()
def fail_websocket_config(*_args, **_kwargs): def fail_websocket_config(*_args, **_kwargs):
@ -963,6 +990,30 @@ class TestMainMenuUpdate:
assert config.model_presets["primary"].provider == "openrouter" assert config.model_presets["primary"].provider == "openrouter"
assert config.model_presets["primary"].model == "openai/gpt-4o-mini" 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): 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.""" """OpenAI should support key-only setup without storing a default base URL."""
config = Config() config = Config()
@ -1008,6 +1059,27 @@ class TestMainMenuUpdate:
assert config.model_presets["primary"].provider == "custom" assert config.model_presets["primary"].provider == "custom"
assert config.model_presets["primary"].model == "custom-model" 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): 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.""" """Quick Start should not create a ready-looking config without an API key."""
config = Config() config = Config()
@ -1447,12 +1519,12 @@ class TestModelPresetWizard:
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import ModelPresetConfig
config = Config() config = Config()
config.model_presets["old"] = ModelPresetConfig(model="x") config.model_presets["old - preset"] = ModelPresetConfig(model="x")
_MODEL_PRESET_CACHE.clear() _MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update({"old", "default"}) _MODEL_PRESET_CACHE.update({"old - preset", "default"})
responses = iter([ responses = iter([
"old (x)", "old - preset - x",
"Delete", "Delete",
True, True,
"<- Back", "<- Back",
@ -1485,8 +1557,8 @@ class TestModelPresetWizard:
_configure_model_presets(config) _configure_model_presets(config)
assert "old" not in config.model_presets assert "old - preset" not in config.model_presets
assert "old" not in _MODEL_PRESET_CACHE assert "old - preset" not in _MODEL_PRESET_CACHE
_MODEL_PRESET_CACHE.clear() _MODEL_PRESET_CACHE.clear()
def test_model_preset_field_handler(self, monkeypatch): def test_model_preset_field_handler(self, monkeypatch):
@ -1505,14 +1577,18 @@ class TestModelPresetWizard:
_MODEL_PRESET_CACHE.clear() _MODEL_PRESET_CACHE.clear()
def test_model_preset_field_handler_clear(self, monkeypatch): def test_model_preset_field_handler_clear(self, monkeypatch):
"""_handle_model_preset_field should clear preset when (clear/unset) chosen.""" """_handle_model_preset_field should clear preset when Clear value is chosen."""
from nanobot.cli.onboard import _MODEL_PRESET_CACHE, _handle_model_preset_field from nanobot.cli.onboard import (
_CLEAR_CHOICE,
_MODEL_PRESET_CACHE,
_handle_model_preset_field,
)
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
_MODEL_PRESET_CACHE.clear() _MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.add("fast") _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") defaults = AgentDefaults(model_preset="fast")
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast") _handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")