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:
@@ -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:
|
||||
|
||||
|
||||
+108
-57
@@ -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,52 +1429,90 @@ 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 = _input_text(f"{answer} API key", "", "str")
|
||||
if api_key is None:
|
||||
return False
|
||||
api_key = api_key.strip()
|
||||
if not api_key:
|
||||
console.print("[yellow]! API key is required for Quick Start[/yellow]")
|
||||
return False
|
||||
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
|
||||
api_key = api_key.strip()
|
||||
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]
|
||||
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,9 +1526,13 @@ def _configure_quick_start_provider(config: Config) -> bool:
|
||||
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
|
||||
return False
|
||||
|
||||
provider_config.api_key = api_key
|
||||
if api_base and not provider_config.api_base:
|
||||
provider_config.api_base = api_base
|
||||
if api_key is not None:
|
||||
provider_config.api_key = api_key
|
||||
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(
|
||||
config,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user