mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
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:
parent
eb14720381
commit
e5294002ed
@ -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:
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user