Files
nanobot/nanobot/cli/onboard.py
T

2132 lines
76 KiB
Python

"""Interactive onboarding questionnaire for nanobot."""
import asyncio
import json
import types
from collections.abc import Callable, Iterable, Sized
from contextlib import suppress
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal, NamedTuple, TypeVar, cast, get_args, get_origin
try:
import questionary
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
questionary = None
from loguru import logger
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
from nanobot.cli.models import (
format_token_count,
get_model_context_limit,
get_model_suggestions,
)
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
console = Console()
_ModelT = TypeVar("_ModelT", bound=BaseModel)
@dataclass
class OnboardResult:
"""Result of an onboarding session."""
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
is_oauth: bool
default_model: str
class _QuickStartEndpointChoice(NamedTuple):
"""Provider endpoint option used by Quick Start."""
label: str
api_base: str
# --- Field Hints for Select Fields ---
# Maps field names to (choices, hint_text)
# To add a new select field with hints, add an entry:
# "field_name": (["choice1", "choice2", ...], "hint text for the field")
_SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
"reasoning_effort": (
["low", "medium", "high"],
"low / medium / high - enables LLM thinking mode",
),
}
# --- Key Bindings for Navigation ---
_BACK_PRESSED = object() # Sentinel value for back navigation
# Cache of model-preset names populated at runtime so that field handlers can
# offer existing presets as choices (e.g. AgentDefaults.model_preset).
_MODEL_PRESET_CACHE: set[str] = set()
_QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible"
_QUICK_START_OAUTH_PROVIDERS = {"openai_codex"}
_CLEAR_CHOICE = "Clear value"
_QUICK_START_MENU_CHOICE = "[Q] Quick Start"
_QUICK_START_STEPS = ("Provider setup", "WebSocket channel", "Review")
_QUICK_START_ENDPOINT_CHOICES: dict[str, tuple[_QuickStartEndpointChoice, ...]] = {
"zhipu": (
_QuickStartEndpointChoice("Standard API", "https://open.bigmodel.cn/api/paas/v4"),
_QuickStartEndpointChoice("Coding Plan", "https://open.bigmodel.cn/api/coding/paas/v4"),
),
"minimax": (
_QuickStartEndpointChoice("Global API", "https://api.minimax.io/v1"),
_QuickStartEndpointChoice("Mainland China Token Plan", "https://api.minimaxi.com/v1"),
),
"minimax_anthropic": (
_QuickStartEndpointChoice("Global Anthropic API", "https://api.minimax.io/anthropic"),
_QuickStartEndpointChoice(
"Mainland China Anthropic Token Plan",
"https://api.minimaxi.com/anthropic",
),
),
"stepfun": (
_QuickStartEndpointChoice("Standard API", "https://api.stepfun.com/v1"),
_QuickStartEndpointChoice("Step Plan", "https://api.stepfun.ai/step_plan/v1"),
),
"xiaomi_mimo": (
_QuickStartEndpointChoice("Standard API", "https://api.xiaomimimo.com/v1"),
_QuickStartEndpointChoice("Token Plan", "https://token-plan-sgp.xiaomimimo.com/v1"),
),
}
# Low-contrast terminal palette inspired by JetBrains Darcula/Islands.
_UI_ACCENT = "#6B9BFA"
_UI_BORDER = "#4E5254"
_UI_TEXT = "#A9B7C6"
_UI_MUTED = "#80868B"
_UI_SUCCESS = "#6AAB73"
_PROMPT_ESCAPE_TIMEOUT_SECONDS = 0.05
_CHANNEL_LOGIN_CHOICE = "Login with QR/link"
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
def _get_questionary() -> Any:
"""Return questionary or raise a clear error when wizard deps are unavailable."""
if questionary is None:
raise RuntimeError(
"Interactive onboarding requires the optional 'questionary' dependency. "
"Install project dependencies and rerun with --wizard."
)
return cast(Any, questionary)
def _select_with_back(
prompt: str, choices: list[str], default: str | None = None
) -> str | None | object:
"""Select with Escape/Left arrow support for going back.
Args:
prompt: The prompt text to display.
choices: List of choices to select from. Must not be empty.
default: The default choice to pre-select. If not in choices, first item is used.
Returns:
_BACK_PRESSED sentinel if user pressed Escape or Left arrow
The selected choice string if user confirmed
None if user cancelled (Ctrl+C)
"""
import shutil
from prompt_toolkit.application import Application
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.styles import Style
# Validate choices
if not choices:
logger.warning("Empty choices list provided to _select_with_back")
return None
# Find default index
selected_index = 0
if default and default in choices:
selected_index = choices.index(default)
# State holder for the result
state: dict[str, str | None | object] = {"result": None}
terminal_lines = shutil.get_terminal_size((80, 24)).lines
visible_count = min(len(choices), max(1, terminal_lines - 3))
# Build menu items (uses closure over selected_index)
def get_menu_text() -> list[tuple[str, str]]:
items: list[tuple[str, str]] = []
start, end = _choice_viewport(selected_index, len(choices), visible_count)
for i in range(start, end):
choice = choices[i]
if i == selected_index:
items.append(("class:selected", f"> {choice}\n"))
else:
items.append(("", f" {choice}\n"))
return items
# Create layout
menu_control = FormattedTextControl(cast(Any, get_menu_text), show_cursor=False)
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
def get_prompt_text() -> list[tuple[str, str]]:
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
return [("class:question", f"{prompt}{suffix}")]
prompt_control = FormattedTextControl(cast(Any, get_prompt_text), show_cursor=False)
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
layout = Layout(HSplit([prompt_window, menu_window]))
# Key bindings
bindings = KeyBindings()
# KeyBindings consumes these handlers through decorator registration.
@bindings.add(Keys.Up)
def _up(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
nonlocal selected_index
selected_index = (selected_index - 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Down)
def _down(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
nonlocal selected_index
selected_index = (selected_index + 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Enter)
def _enter(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = choices[selected_index]
event.app.exit()
@bindings.add("escape")
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.Left)
def _left(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.ControlC)
def _ctrl_c(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = None
event.app.exit()
# Style
style = Style.from_dict({
"selected": f"fg:{_UI_ACCENT} bold",
"question": f"fg:{_UI_TEXT}",
})
app = Application[object](layout=layout, key_bindings=bindings, style=style)
app.ttimeoutlen = 0.05
app.timeoutlen = 0.05
try:
app.run()
except Exception:
logger.exception("Error in select prompt")
return None
return state["result"]
def _choice_viewport(selected_index: int, total: int, visible_count: int) -> tuple[int, int]:
"""Return the visible slice for a long terminal menu."""
if total <= 0:
return 0, 0
visible_count = max(1, min(visible_count, total))
selected_index = max(0, min(selected_index, total - 1))
half = visible_count // 2
start = selected_index - half
start = max(0, min(start, total - visible_count))
return start, start + visible_count
# --- Type Introspection ---
class FieldTypeInfo(NamedTuple):
"""Result of field type introspection."""
type_name: str
inner_type: Any
def _get_field_type_info(field_info: FieldInfo) -> FieldTypeInfo:
"""Extract field type info from Pydantic field."""
annotation = field_info.annotation
if annotation is None:
return FieldTypeInfo("str", None)
origin = get_origin(annotation)
args = get_args(annotation)
if origin is types.UnionType:
non_none_args = [a for a in args if a is not type(None)]
if len(non_none_args) == 1:
annotation = non_none_args[0]
origin = get_origin(annotation)
args = get_args(annotation)
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
origin_name = getattr(origin, "__name__", None)
if origin is list or origin_name == "List":
return FieldTypeInfo("list", args[0] if args else str)
if origin is dict or origin_name == "Dict":
return FieldTypeInfo("dict", None)
for py_type, name in _simple_types.items():
if annotation is py_type:
return FieldTypeInfo(name, None)
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return FieldTypeInfo("model", annotation)
if origin is Literal:
return FieldTypeInfo("literal", list(args))
return FieldTypeInfo("str", None)
def _get_field_display_name(field_key: str, field_info: FieldInfo | None) -> str:
"""Get display name for a field."""
if field_info and field_info.description:
return field_info.description
name = field_key
suffix_map = {
"_s": " seconds",
"_ms": " ms",
"_url": " URL",
"_path": " Path",
"_id": " ID",
"_key": " Key",
"_token": " Token",
}
for suffix, replacement in suffix_map.items():
if name.endswith(suffix):
name = name[: -len(suffix)] + replacement
break
return name.replace("_", " ").title()
# --- Sensitive Field Masking ---
_SENSITIVE_KEYWORDS = frozenset({"api_key", "token", "secret", "password", "credentials"})
def _is_sensitive_field(field_name: str) -> bool:
"""Check if a field name indicates sensitive content."""
return any(kw in field_name.lower() for kw in _SENSITIVE_KEYWORDS)
def _mask_value(value: str) -> str:
"""Mask a sensitive value, showing only the last 4 characters."""
if len(value) <= 4:
return "****"
return "*" * (len(value) - 4) + value[-4:]
# --- Value Formatting ---
def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
"""Single recursive entry point for safe value display. Handles any depth."""
if value is None or value == "" or value == {} or value == []:
return "[dim]not set[/dim]" if rich else "[not set]"
if _is_sensitive_field(field_name) and isinstance(value, str):
masked = _mask_value(value)
return f"[dim]{masked}[/dim]" if rich else masked
if isinstance(value, BaseModel):
model_parts: list[str] = []
for fname, _finfo in type(value).model_fields.items():
fval = getattr(value, fname, None)
formatted = _format_value(fval, rich=False, field_name=fname)
if formatted != "[not set]":
model_parts.append(f"{fname}={formatted}")
return (
", ".join(model_parts)
if model_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
if isinstance(value, list):
return ", ".join(str(v) for v in cast(list[Any], value))
if isinstance(value, dict):
# Handle dicts containing BaseModel instances
mapping_parts: list[str] = []
for k, v in cast(dict[Any, Any], value).items():
formatted = _format_value(v, rich=False, field_name=str(k))
mapping_parts.append(f"{k}: {formatted}")
return (
", ".join(mapping_parts)
if mapping_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
return str(value)
def _format_value_for_input(value: Any, field_type: str) -> str:
"""Format a value for use as input default."""
if value is None or value == "":
return ""
if field_type == "list" and isinstance(value, list):
return ",".join(str(v) for v in cast(list[Any], value))
if field_type == "dict" and isinstance(value, dict):
return json.dumps(value)
return str(value)
def _validate_field_constraint(value: Any, field_info: FieldInfo | None) -> str | None:
"""Validate a value against Pydantic Field constraints.
Returns an error message string if validation fails, None if valid.
Uses attribute-based detection to handle Pydantic v2 internal types.
"""
if field_info is None or not hasattr(field_info, "metadata"):
return None
for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge") and isinstance(value, (int, float)):
if value < m.ge:
return f"Value must be >= {m.ge}"
if hasattr(m, "gt") and isinstance(value, (int, float)):
if value <= m.gt:
return f"Value must be > {m.gt}"
if hasattr(m, "le") and isinstance(value, (int, float)):
if value > m.le:
return f"Value must be <= {m.le}"
if hasattr(m, "lt") and isinstance(value, (int, float)):
if value >= m.lt:
return f"Value must be < {m.lt}"
if hasattr(m, "min_length") and hasattr(value, "__len__"):
if len(cast(Sized, value)) < m.min_length:
return f"Length must be >= {m.min_length}"
if hasattr(m, "max_length") and hasattr(value, "__len__"):
if len(cast(Sized, value)) > m.max_length:
return f"Length must be <= {m.max_length}"
return None
def _get_constraint_hint(field_info: FieldInfo | None) -> str:
"""Derive a human-readable constraint hint from field metadata.
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 ""
ge_val = None
le_val = None
for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge"):
ge_val = m.ge
if hasattr(m, "le"):
le_val = m.le
if ge_val is not None and le_val is not None:
return f" - {ge_val}-{le_val}"
if ge_val is not None:
return f" - >= {ge_val}"
if le_val is not None:
return f" - <= {le_val}"
return ""
# --- Rich UI Components ---
def _show_config_panel(
display_name: str,
model: BaseModel,
fields: list[tuple[str, FieldInfo]],
) -> None:
"""Display current configuration as a rich table."""
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Field", style=_UI_ACCENT)
table.add_column("Value")
for fname, field_info in fields:
value = getattr(model, fname, None)
display = _get_field_display_name(fname, field_info)
formatted = _format_value(value, rich=True, field_name=fname)
table.add_row(display, formatted)
console.print(Panel(table, title=f"[bold {_UI_TEXT}]{display_name}[/]", border_style=_UI_BORDER))
def _show_main_menu_header() -> None:
"""Display the main menu header."""
from nanobot import __logo__, __version__
console.print()
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, credentials, and model.[/]")
body.add_row(
f"[{_UI_MUTED}]Use Advanced later for chat apps, tools, or provider-specific details.[/]"
)
console.print(
Panel(
body,
title=f"[bold {_UI_TEXT}]Setup Wizard[/]",
border_style=_UI_BORDER,
padding=(1, 2),
)
)
console.print()
def _show_section_header(title: str, subtitle: str = "") -> None:
"""Display a section header."""
console.print()
if subtitle:
console.print(
Panel(
f"[{_UI_MUTED}]{subtitle}[/]",
title=f"[bold {_UI_TEXT}]{title}[/]",
border_style=_UI_BORDER,
padding=(1, 2),
)
)
else:
console.print(Panel("", title=f"[bold {_UI_TEXT}]{title}[/]", border_style=_UI_BORDER))
# --- Input Handlers ---
def _input_bool(display_name: str, current: bool | None) -> bool | None:
"""Get boolean input via confirm dialog."""
return _get_questionary().confirm(
display_name,
default=bool(current) if current is not None else False,
).ask()
def _input_back_key_bindings() -> KeyBindings:
"""Return key bindings that make Escape behave like a local back action."""
bindings = KeyBindings()
# KeyBindings consumes this handler through decorator registration.
@bindings.add("escape")
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
event.app.exit(result=_BACK_PRESSED)
return bindings
def _ask_prompt(prompt: Any) -> Any:
"""Ask a questionary prompt with responsive Escape handling."""
app = getattr(prompt, "application", None)
if app is not None:
if hasattr(app, "ttimeoutlen"):
app.ttimeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS
if hasattr(app, "timeoutlen"):
app.timeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS
return prompt.ask()
def _input_text(
display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any:
"""Get text input and parse based on field type."""
default = _format_value_for_input(current, field_type)
value = _ask_prompt(
_get_questionary().text(
f"{display_name}:",
default=default,
key_bindings=_input_back_key_bindings(),
)
)
if value is _BACK_PRESSED or value is None:
return None if value is None else _BACK_PRESSED
if field_type == "int":
try:
parsed = int(value)
except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
return None
if field_info:
error = _validate_field_constraint(parsed, field_info)
if error:
console.print(f"[yellow]! {error}, value not saved[/yellow]")
return None
return parsed
elif field_type == "float":
try:
parsed = float(value)
except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
return None
if field_info:
error = _validate_field_constraint(parsed, field_info)
if error:
console.print(f"[yellow]! {error}, value not saved[/yellow]")
return None
return parsed
elif field_type == "list":
return [v.strip() for v in value.split(",") if v.strip()]
elif field_type == "dict":
try:
return json.loads(value)
except json.JSONDecodeError:
console.print("[yellow]! Invalid JSON format, value not saved[/yellow]")
return None
return value
def _input_secret(display_name: str) -> str | None | object:
"""Get a secret value without echoing it when questionary supports password input."""
prompt_factory = getattr(_get_questionary(), "password", None)
if prompt_factory is None:
prompt_factory = _get_questionary().text
value = _ask_prompt(prompt_factory(f"{display_name}:", key_bindings=_input_back_key_bindings()))
if value is _BACK_PRESSED or value is None:
return None if value is None else _BACK_PRESSED
return str(value).strip()
def _input_with_existing(
display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any:
"""Handle input with 'keep existing' option for non-empty values."""
has_existing = current is not None and current != "" and current != {} and current != []
if has_existing and not isinstance(current, list):
choice = _get_questionary().select(
display_name,
choices=["Enter new value", "Keep existing value"],
default="Keep existing value",
).ask()
if choice == "Keep existing value" or choice is None:
return None
return _input_text(display_name, current, field_type, field_info=field_info)
# --- Pydantic Model Configuration ---
def _get_current_provider(model: BaseModel) -> str:
"""Get the current provider setting from a model (if available)."""
if hasattr(model, "provider"):
return getattr(model, "provider", "auto") or "auto"
return "auto"
def _input_model_with_autocomplete(
display_name: str, current: Any, provider: str
) -> str | None | object:
"""Get model input with autocomplete suggestions.
"""
default = str(current) if current else ""
class DynamicModelCompleter(Completer):
"""Completer that dynamically fetches model suggestions."""
def __init__(self, provider_name: str):
self.provider = provider_name
def get_completions(
self,
document: Document,
complete_event: CompleteEvent,
) -> Iterable[Completion]:
_ = complete_event
text = document.text_before_cursor
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
for model in suggestions:
# Skip if model doesn't contain the typed text
if text.lower() not in model.lower():
continue
yield Completion(
model,
start_position=-len(text),
display=model,
)
value = _ask_prompt(
_get_questionary().autocomplete(
f"{display_name}:",
choices=[""], # Placeholder, actual completions from completer
completer=DynamicModelCompleter(provider),
default=default,
key_bindings=_input_back_key_bindings(),
qmark=">",
)
)
if value is _BACK_PRESSED or value is None:
return None if value is None else _BACK_PRESSED
return value
def _input_context_window_with_recommendation(
display_name: str, current: Any, model_obj: BaseModel
) -> int | None | object:
"""Get context window input with option to fetch recommended value."""
current_val = current if current else ""
choices = ["Enter new value"]
if current_val:
choices.append("Keep existing value")
choices.append("[?] Get recommended value")
choice = _get_questionary().select(
display_name,
choices=choices,
default="Enter new value",
).ask()
if choice is None:
return None
if choice == "Keep existing value":
return None
if choice == "[?] Get recommended value":
# Get the model name from the model object
model_name = getattr(model_obj, "model", None)
if not model_name:
console.print("[yellow]! Please configure the model field first[/yellow]")
return None
provider = _get_current_provider(model_obj)
context_limit = get_model_context_limit(model_name, provider)
if context_limit:
console.print(
f"[{_UI_SUCCESS}]+ Recommended context window: "
f"{format_token_count(context_limit)} tokens[/]"
)
return context_limit
else:
console.print("[yellow]! Could not fetch model info, please enter manually[/yellow]")
# Fall through to manual input
# Manual input
value = _get_questionary().text(
f"{display_name}:",
default=str(current_val) if current_val else "",
key_bindings=_input_back_key_bindings(),
).ask()
if value is _BACK_PRESSED:
return _BACK_PRESSED
if value is None or value == "":
return None
try:
return int(value)
except ValueError:
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
return None
def _handle_model_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'model' field with autocomplete and context-window auto-fill."""
provider = _get_current_provider(working_model)
new_value = _input_model_with_autocomplete(field_display, current_value, provider)
if new_value is _BACK_PRESSED:
return
if new_value is not None and new_value != current_value:
setattr(working_model, field_name, new_value)
_try_auto_fill_context_window(working_model, cast(str, new_value))
def _handle_context_window_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle context_window_tokens with recommendation lookup."""
new_value = _input_context_window_with_recommendation(
field_display, current_value, working_model
)
if new_value is _BACK_PRESSED:
return
if new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_model_preset_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'model_preset' field with a list of existing presets."""
preset_names = sorted(_MODEL_PRESET_CACHE)
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_CHOICE:
setattr(working_model, field_name, None)
elif new_value is not None:
setattr(working_model, field_name, new_value)
def _set_field_from_choices(
working_model: BaseModel, field_name: str, field_display: str,
choices: list[str], default_choice: str
) -> None:
"""Prompt to pick one of ``choices`` and set the field (no-op on back/cancel)."""
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value is not None:
setattr(working_model, field_name, new_value)
def _handle_provider_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'provider' field with a list of registered LLM providers."""
choices = ["auto"] + sorted(_get_provider_names().keys())
default_choice = str(current_value) if current_value else "auto"
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
def _handle_fallback_models_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = (
list(cast(list[Any], current_value))
if isinstance(current_value, list)
else []
)
preset_names = sorted(_MODEL_PRESET_CACHE)
while True:
console.clear()
console.print(f"[bold]{field_display}[/bold]")
if items:
for idx, item in enumerate(items, 1):
if isinstance(item, InlineFallbackConfig):
console.print(f" {idx}. {item.model} - {item.provider} inline")
else:
console.print(f" {idx}. {item}")
else:
console.print(" [dim]empty[/dim]")
console.print()
choices = ["[+] Add preset"]
if items:
choices.append("[-] Remove last")
choices.append("[X] Clear all")
choices.append("[Done]")
choices.append("<- Back")
answer = _get_questionary().select(
"Manage fallback models:",
choices=choices,
qmark=">",
).ask()
if answer is None or answer == "<- Back":
return
if answer == "[Done]":
setattr(working_model, field_name, items)
return
if answer == "[+] Add preset":
if not preset_names:
console.print("[yellow]! No presets defined yet.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
add_choices = [p for p in preset_names if p not in items]
if not add_choices:
console.print("[yellow]! All presets already added.[/yellow]")
_get_questionary().press_any_key_to_continue().ask()
continue
picked = _select_with_back("Select preset:", add_choices)
if picked is _BACK_PRESSED or picked is None:
continue
items.append(picked)
elif answer == "[-] Remove last" and items:
items.pop()
elif answer == "[X] Clear all" and items:
items.clear()
def _handle_search_provider_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the web-search 'provider' field with the search-engine list."""
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
choices = [opt["name"] for opt in SEARCH_PROVIDER_OPTIONS]
default_choice = current_value if current_value in choices else choices[0]
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
_FIELD_HANDLERS: dict[str, Any] = {
"model": _handle_model_field,
"context_window_tokens": _handle_context_window_field,
"model_preset": _handle_model_preset_field,
"provider": _handle_provider_field,
"fallback_models": _handle_fallback_models_field,
}
def _resolve_field_handler(model: BaseModel, field_name: str) -> Any:
"""Resolve the handler for a field. WebSearchConfig shares the bare "provider"
name with LLM configs but needs the search-engine picker, not the LLM list."""
if field_name == "provider":
from nanobot.agent.tools.web import WebSearchConfig
if isinstance(model, WebSearchConfig):
return _handle_search_provider_field
return _FIELD_HANDLERS.get(field_name)
def _is_str_or_none(annotation: Any) -> bool:
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
origin = get_origin(annotation)
if origin is None:
return False
args = get_args(annotation)
return str in args and type(None) in args
def _configure_pydantic_model(
model: _ModelT,
display_name: str,
*,
skip_fields: set[str] | None = None,
) -> _ModelT | None:
"""Configure a Pydantic model interactively.
Returns the updated model when the user selects "Done" or navigates back.
Cancel actions discard the section draft.
"""
skip_fields = skip_fields or set()
working_model = model.model_copy(deep=True)
fields: list[tuple[str, FieldInfo]] = [
(name, info)
for name, info in type(working_model).model_fields.items()
if name not in skip_fields
]
if not fields:
console.print(f"[dim]{display_name}: No configurable fields[/dim]")
return working_model
def get_choices() -> list[str]:
items: list[str] = []
for fname, finfo in fields:
value = getattr(working_model, fname, None)
display = _get_field_display_name(fname, finfo)
formatted = _format_value(value, rich=False, field_name=fname)
items.append(f"{display}: {formatted}")
return items + ["[Done]"]
last_field_name: str | None = None
while True:
console.clear()
_show_config_panel(display_name, working_model, fields)
choices = get_choices()
default_choice = None
if last_field_name:
for idx, (fname, _) in enumerate(fields):
if fname == last_field_name:
default_choice = choices[idx]
break
answer = _select_with_back(
"Select field to configure:", choices, default=default_choice
)
if answer is _BACK_PRESSED:
return working_model
if answer is None:
return None
if answer == "[Done]":
return working_model
field_idx = next((i for i, c in enumerate(choices) if c == answer), -1)
if field_idx < 0 or field_idx >= len(fields):
return None
last_field_name = fields[field_idx][0]
field_name, field_info = fields[field_idx]
current_value = getattr(working_model, field_name, None)
ftype = _get_field_type_info(field_info)
field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)
# Nested Pydantic model - recurse
if ftype.type_name == "model":
nested = current_value
created = nested is None
if nested is None and ftype.inner_type:
nested = ftype.inner_type()
if nested and isinstance(nested, BaseModel):
updated = _configure_pydantic_model(nested, field_display)
if updated is not None:
setattr(working_model, field_name, updated)
elif created:
setattr(working_model, field_name, None)
continue
# Registered special-field handlers
handler = _resolve_field_handler(working_model, field_name)
if handler:
handler(working_model, field_name, field_display, current_value)
continue
# 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_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_CHOICE:
setattr(working_model, field_name, None)
elif new_value is not None:
setattr(working_model, field_name, new_value)
continue
# Generic field input
if ftype.type_name == "literal" and ftype.inner_type:
select_choices = [str(v) for v in ftype.inner_type]
default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]
new_value = _select_with_back(field_display, select_choices, default=default_choice)
if new_value is _BACK_PRESSED:
continue
if new_value is not None:
setattr(working_model, field_name, new_value)
continue
if ftype.type_name == "bool":
new_value = _input_bool(field_display, current_value)
else:
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
if new_value is _BACK_PRESSED:
continue
if new_value is not None:
# Normalize empty string to None for optional string fields so that
# clearing an api_key / api_base actually removes the value.
if new_value == "" and _is_str_or_none(field_info.annotation):
new_value = None
setattr(working_model, field_name, new_value)
def _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None:
"""Try to auto-fill context_window_tokens if it's at default value.
Note:
This function imports AgentDefaults from nanobot.config.schema to get
the default context_window_tokens value. If the schema changes, this
coupling needs to be updated accordingly.
"""
# Check if context_window_tokens field exists
if not hasattr(model, "context_window_tokens"):
return
current_context = getattr(model, "context_window_tokens", None)
# Check if current value is the default
# We only auto-fill if the user hasn't changed it from default
from nanobot.config.schema import AgentDefaults
default_context = AgentDefaults.model_fields["context_window_tokens"].default
if current_context != default_context:
return # User has customized it, don't override
provider = _get_current_provider(model)
context_limit = get_model_context_limit(new_model_name, provider)
if context_limit:
setattr(model, "context_window_tokens", context_limit)
console.print(
f"[{_UI_SUCCESS}]+ Auto-filled context window: "
f"{format_token_count(context_limit)} tokens[/]"
)
else:
console.print("[dim]Could not auto-fill context window - model not in database[/dim]")
# --- Model Preset Configuration ---
def _sync_preset_cache(config: Config) -> None:
"""Synchronise the module-level preset name cache from config."""
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.update(config.model_presets.keys())
def _validate_nonempty_name(text: str) -> bool | str:
return True if text and text.strip() else "Name cannot be empty"
def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD)."""
_sync_preset_cache(config)
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():
choice = f"{name} - {preset.model}"
choices.append(choice)
choice_to_preset[choice] = name
choices.append("[+] Add new preset")
choices.append("<- Back")
return choices, choice_to_preset
last_preset_name: str | None = None
while True:
try:
console.clear()
_show_section_header(
"Model Presets",
"Create, edit or delete named model presets for quick switching",
)
choices, choice_to_preset = get_preset_choices()
default_choice = None
if last_preset_name:
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
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
assert isinstance(answer, str)
if answer == "[+] Add new preset":
name_input = _get_questionary().text(
"Preset name:",
validate=_validate_nonempty_name,
).ask()
if not name_input:
continue
name = name_input.strip()
if name in config.model_presets:
console.print(f"[yellow]! Preset '{name}' already exists[/yellow]")
_pause()
continue
if name == "default":
console.print(
"[yellow]! 'default' is reserved; it is generated from Agent Settings[/yellow]"
)
_pause()
continue
new_preset = ModelPresetConfig(model="")
updated = _configure_pydantic_model(new_preset, f"New Preset: {name}")
if updated is not None:
config.model_presets[name] = updated
_sync_preset_cache(config)
last_preset_name = name
continue
# Editing / deleting an existing preset
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
last_preset_name = preset_name
choices = ["Edit", "Cancel"]
if preset_name != "default":
choices.insert(1, "Delete")
action = _select_with_back(
f"Preset: {preset_name}",
choices,
default="Edit",
)
if action is _BACK_PRESSED or action == "Cancel" or action is None:
continue
if action == "Delete":
confirm = _get_questionary().confirm(
f"Delete preset '{preset_name}'?",
default=False,
).ask()
if confirm:
del config.model_presets[preset_name]
_sync_preset_cache(config)
last_preset_name = None
continue
if action == "Edit":
updated = _configure_pydantic_model(preset, f"Edit Preset: {preset_name}")
if updated is not None:
config.model_presets[preset_name] = updated
_sync_preset_cache(config)
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
break
# --- Provider Configuration ---
@lru_cache(maxsize=1)
def _get_provider_info() -> dict[str, tuple[str, bool, bool, str]]:
"""Get provider info from registry (cached)."""
from nanobot.providers.registry import PROVIDERS
return {
spec.name: (
spec.display_name or spec.name,
spec.is_gateway,
spec.is_local,
spec.default_api_base,
)
for spec in PROVIDERS
if not spec.is_oauth
}
def _get_provider_names() -> dict[str, str]:
"""Get provider display names."""
info = _get_provider_info()
return {name: data[0] for name, data in info.items() if name}
def _configure_provider(config: Config, provider_name: str) -> None:
"""Configure a single LLM provider."""
provider_config = getattr(config.providers, provider_name, None)
if provider_config is None:
console.print(f"[red]Unknown provider: {provider_name}[/red]")
return
display_name = _get_provider_names().get(provider_name, provider_name)
info = _get_provider_info()
default_api_base = info.get(provider_name, (None, None, None, None))[3]
if default_api_base and not provider_config.api_base:
provider_config.api_base = default_api_base
updated_provider = _configure_pydantic_model(
provider_config,
display_name,
)
if updated_provider is not None:
setattr(config.providers, provider_name, updated_provider)
def _configure_providers(config: Config) -> None:
"""Configure LLM providers."""
def get_provider_choices() -> list[str]:
"""Build provider choices with config status indicators."""
choices: list[str] = []
for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None)
if provider and provider.api_key:
choices.append(f"{display} *")
else:
choices.append(display)
return choices + ["<- Back"]
last_provider_key: str | None = None
while True:
try:
console.clear()
_show_section_header("LLM Providers", "Select a provider to configure API key and endpoint")
choices = get_provider_choices()
default_choice = None
if last_provider_key:
display = _get_provider_names().get(last_provider_key)
if display:
for c in choices:
if c.replace(" *", "") == display:
default_choice = c
break
answer = _select_with_back(
"Select provider:", choices, default=default_choice
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
# Type guard: answer is now guaranteed to be a string
assert isinstance(answer, str)
# Extract provider name from choice (remove " *" suffix if present)
provider_name = answer.replace(" *", "")
# Find the actual provider key from display names
for name, display in _get_provider_names().items():
if display == provider_name:
last_provider_key = name
_configure_provider(config, name)
break
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
break
# --- Channel Configuration ---
@lru_cache(maxsize=1)
def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:
"""Get channel info (display name + config class) from channel modules."""
import importlib
from nanobot.channels.registry import discover_all
result: dict[str, tuple[str, type[BaseModel]]] = {}
for name, channel_cls in discover_all().items():
try:
mod = importlib.import_module(channel_cls.__module__)
config_name = channel_cls.__name__.replace("Channel", "Config")
config_cls = getattr(mod, config_name, None)
if config_cls and isinstance(config_cls, type) and issubclass(config_cls, BaseModel):
display_name = getattr(channel_cls, "display_name", name.capitalize())
result[name] = (display_name, config_cls)
except Exception:
logger.warning("Failed to load channel module: {}", name)
return result
def _get_channel_names() -> dict[str, str]:
"""Get channel display names."""
return {name: info[0] for name, info in _get_channel_info().items()}
def _get_channel_config_class(channel: str) -> type[BaseModel] | None:
"""Get channel config class."""
entry = _get_channel_info().get(channel)
return entry[1] if entry else None
def _get_channel_class(channel: str) -> type[Any] | None:
"""Get channel implementation class."""
from nanobot.channels.registry import discover_all
return discover_all().get(channel)
def _channel_supports_login(channel_cls: type[Any] | None) -> bool:
"""Return True when a channel overrides BaseChannel.login."""
if channel_cls is None:
return False
from nanobot.channels.base import BaseChannel
return getattr(channel_cls, "login", None) is not BaseChannel.login
def _run_channel_login(
config: Config,
channel_name: str,
model: BaseModel,
display_name: str,
) -> bool:
"""Run a channel's interactive login and enable it only on success."""
channel_cls = _get_channel_class(channel_name)
if channel_cls is None:
console.print(f"[red]Unknown channel: {channel_name}[/red]")
return False
if not _channel_supports_login(channel_cls):
return False
if hasattr(model, "enabled"):
setattr(model, "enabled", True)
console.print(f"[{_UI_ACCENT}]Starting {display_name} login...[/]")
try:
channel = channel_cls(model, bus=None)
success = asyncio.run(channel.login(force=False))
except KeyboardInterrupt:
console.print("\n[dim]Login cancelled.[/dim]")
return False
except Exception as exc:
logger.exception("{} login failed", display_name)
console.print(f"[red]{display_name} login failed:[/red] {exc}")
return False
if not success:
console.print(f"[yellow]! {display_name} login did not complete; channel was not enabled[/yellow]")
return False
setattr(config.channels, channel_name, model.model_dump(by_alias=True, exclude_none=True))
console.print(f"[{_UI_SUCCESS}]{display_name} enabled[/]")
return True
def _configure_channel(config: Config, channel_name: str) -> None:
"""Configure a single channel."""
channel_dict = getattr(config.channels, channel_name, None)
if channel_dict is None:
channel_dict = {}
setattr(config.channels, channel_name, channel_dict)
display_name = _get_channel_names().get(channel_name, channel_name)
config_cls = _get_channel_config_class(channel_name)
if config_cls is None:
console.print(f"[red]No configuration class found for {display_name}[/red]")
return
model = config_cls.model_validate(channel_dict) if channel_dict else config_cls()
channel_cls = _get_channel_class(channel_name)
if _channel_supports_login(channel_cls):
action = _select_with_back(
f"Configure {display_name}:",
[_CHANNEL_LOGIN_CHOICE, _CHANNEL_ADVANCED_CHOICE, "<- Back"],
default=_CHANNEL_LOGIN_CHOICE,
)
if action is _BACK_PRESSED or action is None or action == "<- Back":
return
if action == _CHANNEL_LOGIN_CHOICE:
_run_channel_login(config, channel_name, model, display_name)
return
updated_channel = _configure_pydantic_model(
model,
display_name,
)
if updated_channel is not None:
new_dict = updated_channel.model_dump(by_alias=True, exclude_none=True)
setattr(config.channels, channel_name, new_dict)
def _configure_channels(config: Config) -> None:
"""Configure chat channels."""
channel_names = list(_get_channel_names().keys())
choices = channel_names + ["<- Back"]
last_choice: str | None = None
while True:
try:
console.clear()
_show_section_header("Chat Channels", "Select a channel to configure connection settings")
answer = _select_with_back(
"Select channel:", choices, default=last_choice
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
# Type guard: answer is now guaranteed to be a string
assert isinstance(answer, str)
last_choice = answer
_configure_channel(config, answer)
except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]")
break
# --- General Settings ---
_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
"Gateway": ("Gateway Settings", "Configure server host, port", None),
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
}
_SETTINGS_GETTER: dict[str, Callable[[Config], BaseModel]] = {
"Agent Settings": lambda c: c.agents.defaults,
"Channel Common": lambda c: c.channels,
"API Server": lambda c: c.api,
"Gateway": lambda c: c.gateway,
"Tools": lambda c: c.tools,
}
_SETTINGS_SETTER: dict[str, Callable[[Config, BaseModel], None]] = {
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
"Channel Common": lambda c, v: setattr(c, "channels", v),
"API Server": lambda c, v: setattr(c, "api", v),
"Gateway": lambda c, v: setattr(c, "gateway", v),
"Tools": lambda c, v: setattr(c, "tools", v),
}
def _configure_general_settings(config: Config, section: str) -> None:
"""Configure a general settings section (header + model edit + writeback)."""
meta = _SETTINGS_SECTIONS.get(section)
if not meta:
return
display_name, _subtitle, skip = meta
model = _SETTINGS_GETTER[section](config)
updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
if updated is not None:
_SETTINGS_SETTER[section](config, updated)
# --- Summary ---
def _summarize_model(obj: BaseModel) -> list[tuple[str, str]]:
"""Recursively summarize a Pydantic model. Returns list of (field, value) tuples."""
items: list[tuple[str, str]] = []
for field_name, field_info in type(obj).model_fields.items():
value = getattr(obj, field_name, None)
if value is None or value == "" or value == {} or value == []:
continue
display = _get_field_display_name(field_name, field_info)
ftype = _get_field_type_info(field_info)
if ftype.type_name == "model" and isinstance(value, BaseModel):
for nested_field, nested_value in _summarize_model(value):
items.append((f"{display}.{nested_field}", nested_value))
continue
formatted = _format_value(value, rich=False, field_name=field_name)
if formatted != "[not set]":
items.append((display, formatted))
return items
def _print_summary_panel(rows: list[tuple[str, str]], title: str) -> None:
"""Build a two-column summary panel and print it."""
if not rows:
return
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Setting", style=_UI_ACCENT)
table.add_column("Value")
for field, value in rows:
table.add_row(field, value)
console.print(Panel(table, title=f"[bold {_UI_TEXT}]{title}[/]", border_style=_UI_BORDER))
def _show_summary(config: Config) -> None:
"""Display configuration summary using rich."""
console.print()
# Providers
provider_rows: list[tuple[str, str]] = []
for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None)
status = (
f"[{_UI_SUCCESS}]configured[/]"
if (provider and provider.api_key)
else f"[{_UI_MUTED}]not configured[/]"
)
provider_rows.append((display, status))
_print_summary_panel(provider_rows, "LLM Providers")
# Channels
channel_rows: list[tuple[str, str]] = []
for name, display in _get_channel_names().items():
channel = getattr(config.channels, name, None)
if channel:
enabled = (
cast(dict[str, Any], channel).get("enabled", False)
if isinstance(channel, dict)
else getattr(channel, "enabled", False)
)
status = f"[{_UI_SUCCESS}]enabled[/]" if enabled else f"[{_UI_MUTED}]disabled[/]"
else:
status = f"[{_UI_MUTED}]not configured[/]"
channel_rows.append((display, status))
_print_summary_panel(channel_rows, "Chat Channels")
# Model Presets
preset_rows: list[tuple[str, str]] = []
for name, preset in config.model_presets.items():
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
_print_summary_panel(preset_rows, "Model Presets")
# Settings sections
for title, model in [
("Agent Settings", config.agents.defaults),
("Channel Common", config.channels),
("API Server", config.api),
("Gateway", config.gateway),
("Tools", config.tools),
]:
_print_summary_panel(_summarize_model(model), title)
_pause()
def _pause(message: str = "Press Enter to continue...") -> None:
"""Pause for user acknowledgement before clearing the screen."""
_get_questionary().text(message, default="").ask()
# --- Quick Start ---
def _set_primary_quick_start_preset(config: Config, provider_name: str, model: str) -> None:
"""Store the primary preset used by Quick Start."""
config.model_presets["primary"] = ModelPresetConfig(
label="Primary",
model=model,
provider=provider_name,
)
config.agents.defaults.model_preset = "primary"
_sync_preset_cache(config)
def _show_quick_start_progress(active_step: int) -> None:
"""Render a compact step tracker for Quick Start."""
parts: list[str] = []
for idx, label in enumerate(_QUICK_START_STEPS, 1):
if idx < active_step:
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
elif idx == active_step:
parts.append(f"[bold {_UI_ACCENT}]{idx}. {label}[/]")
else:
parts.append(f"[{_UI_MUTED}]{idx}. {label}[/]")
console.print(" " + " -> ".join(parts))
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_transcription_only
or (spec.is_oauth and spec.name not in _QUICK_START_OAUTH_PROVIDERS)
):
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,
is_oauth=spec.is_oauth,
default_model=spec.builtin_models[0].id if spec.builtin_models else "",
)
return result
def _get_quick_start_provider_choices() -> dict[str, str]:
"""Return Quick Start provider display choices."""
choices: dict[str, str] = {}
for provider_name, info in _get_quick_start_provider_info().items():
choices.setdefault(info.display_name, provider_name)
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 or info.is_oauth))
def _quick_start_codex_proxy(config: Config) -> str | None:
"""Resolve only the Codex proxy without validating unrelated provider secrets."""
proxy_config = Config()
proxy_config.providers.openai_codex.proxy = config.providers.openai_codex.proxy
return resolve_config_env_vars(proxy_config).providers.openai_codex.proxy or None
def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
"""Authenticate an OAuth provider supported by Quick Start."""
if provider_name != "openai_codex":
console.print(f"[red]OAuth login is not supported for {provider_name}[/red]")
return False
try:
# oauth-cli-kit does not publish type information.
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
get_token,
login_oauth_interactive,
)
except ImportError:
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
return False
try:
proxy = _quick_start_codex_proxy(config)
except ValueError as exc:
console.print(f"[red]{escape(str(exc))}[/red]")
return False
token = None
with suppress(Exception):
token = get_token(proxy=proxy)
if not getattr(token, "access", None):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
try:
token = login_oauth_interactive(
print_fn=lambda message: console.print(message, markup=False),
prompt_fn=lambda prompt: _get_questionary().text(prompt).ask() or "",
proxy=proxy,
)
except Exception as exc:
console.print(f"[red]OAuth login failed: {escape(str(exc))}[/red]")
return False
if not getattr(token, "access", None):
console.print("[red]OAuth login failed[/red]")
return False
account = getattr(token, "account_id", None)
suffix = f" [dim]{escape(str(account))}[/dim]" if account else ""
console.print(f"[green]Authenticated with OpenAI Codex[/green]{suffix}")
return True
def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> bool:
"""Return whether Quick Start can load a usable OAuth token."""
if provider_name != "openai_codex":
return False
try:
# oauth-cli-kit does not publish type information.
from oauth_cli_kit import get_token # pyright: ignore[reportMissingTypeStubs]
proxy = _quick_start_codex_proxy(config)
token = get_token(proxy=proxy)
except Exception:
return False
return bool(getattr(token, "access", None))
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 provider_name in _QUICK_START_ENDPOINT_CHOICES:
return False
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 _select_quick_start_api_base(
provider_name: str,
provider_display: str,
info: _QuickStartProviderInfo | None,
) -> tuple[str, bool] | None | object:
"""Return the api_base and whether the user explicitly selected or entered it."""
endpoint_choices = _QUICK_START_ENDPOINT_CHOICES.get(provider_name)
if endpoint_choices:
choices = {choice.label: choice.api_base for choice in endpoint_choices}
answer = _select_with_back(
f"Which {provider_display} endpoint should Quick Start use?",
list(choices) + ["<- Back"],
default=endpoint_choices[0].label,
)
if answer is _BACK_PRESSED or answer == "<- Back":
return _BACK_PRESSED
if answer is None:
return None
assert isinstance(answer, str)
return choices[answer], True
api_base = info.default_api_base if info else ""
if not _quick_start_requires_base_url(provider_name, info):
return api_base, False
base_answer = _input_text(
"Provider base URL",
api_base,
"str",
)
if base_answer is _BACK_PRESSED:
return _BACK_PRESSED
if base_answer is None:
return None
api_base = base_answer.strip().rstrip("/")
if not api_base:
console.print("[yellow]! Provider base URL is required for this provider[/yellow]")
return None
return api_base, True
def _configure_quick_start_provider(config: Config) -> bool | object:
"""Configure the beginner path from provider credentials and model."""
while True:
_show_quick_start_progress(1)
provider_choices = _get_quick_start_provider_choices()
answer = _select_with_back(
"Which provider do you want to use?",
list(provider_choices) + ["<- Back"],
)
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
return _BACK_PRESSED
assert isinstance(answer, str)
provider_name = provider_choices[answer]
provider_info = _get_quick_start_provider_info().get(provider_name)
api_base = provider_info.default_api_base if provider_info else ""
base_was_prompted = False
if provider_name in _QUICK_START_ENDPOINT_CHOICES:
api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info)
if api_base_result is _BACK_PRESSED:
continue
if api_base_result is None:
return False
api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
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 _BACK_PRESSED:
continue
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
if (
provider_name not in _QUICK_START_ENDPOINT_CHOICES
and _quick_start_requires_base_url(provider_name, provider_info)
):
api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info)
if api_base_result is _BACK_PRESSED:
continue
if api_base_result is None:
return False
api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
provider_config = getattr(config.providers, provider_name, None)
if provider_config is None:
console.print(f"[red]Unknown provider: {provider_name}[/red]")
return False
model = _input_model_with_autocomplete(
"Model ID",
provider_info.default_model if provider_info else "",
provider_name,
)
if model is _BACK_PRESSED:
continue
model = cast(str, model or "").strip()
if not model:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False
if provider_info and provider_info.is_oauth:
if not _quick_start_oauth_login(config, provider_name):
return False
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,
provider_name,
model,
)
return True
def _enable_quick_start_websocket_defaults(config: Config) -> bool:
"""Enable local WebUI with the default WebSocket settings."""
_show_quick_start_progress(2)
console.print(
f"[{_UI_ACCENT}]Quick Start will enable the WebSocket channel for the local WebUI.[/]"
)
console.print(
f"[{_UI_MUTED}]This lets the browser UI at http://127.0.0.1:8765 connect to nanobot.[/]"
)
console.print()
while True:
answer = _get_questionary().confirm(
"Enable WebSocket channel now?",
default=True,
).ask()
if not answer:
console.print(
"[yellow]! Quick Start needs the WebSocket channel for the local WebUI[/yellow]"
)
return False
webui_secret = _input_secret("Set a WebUI password")
if webui_secret is _BACK_PRESSED:
continue
if not webui_secret:
console.print("[yellow]! WebUI password is required when enabling WebSocket[/yellow]")
return False
break
config_cls = _get_channel_config_class("websocket")
if config_cls is None:
console.print("[red]No configuration class found for websocket[/red]")
return False
current: Any = getattr(config.channels, "websocket", None) or {}
model = config_cls.model_validate(current)
if hasattr(model, "enabled"):
setattr(model, "enabled", True)
if hasattr(model, "token_issue_secret"):
setattr(model, "token_issue_secret", webui_secret)
if hasattr(model, "websocket_requires_token"):
setattr(model, "websocket_requires_token", True)
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
return True
def _show_quick_start_summary(config: Config) -> None:
"""Show the small summary users need before returning to the menu."""
_show_quick_start_progress(3)
preset = config.model_presets.get("primary")
provider_label = "AI provider"
credentials_ready = True
credential_name = "API key"
if preset:
provider_config = getattr(config.providers, preset.provider, None)
provider_info = _get_quick_start_provider_info().get(preset.provider)
if provider_info:
provider_label = provider_info.display_name
if provider_info.is_oauth:
credential_name = "OAuth login"
credentials_ready = _quick_start_oauth_is_authenticated(config, preset.provider)
else:
credentials_ready = provider_info.is_local or bool(
provider_config and provider_config.api_key
)
else:
provider_label = _get_provider_names().get(preset.provider, preset.provider)
credentials_ready = bool(provider_config and provider_config.api_key)
status = "Ready"
if not credentials_ready:
status = f"{provider_label} {credential_name} missing"
rows = [
("Status", status),
("WebSocket channel", "enabled"),
]
_print_summary_panel(rows, "Quick Start")
def _configure_quick_start(config: Config) -> bool:
"""First-run path: provider + API key + local WebUI, with advanced settings hidden."""
console.clear()
_show_section_header(
"Quick Start",
"Choose provider endpoint, add credentials and model, then enable the local WebUI channel.",
)
draft = config.model_copy(deep=True)
provider_result = _configure_quick_start_provider(draft)
if provider_result is _BACK_PRESSED:
return False
if not provider_result:
_pause()
return False
if not _enable_quick_start_websocket_defaults(draft):
_pause()
return False
_show_quick_start_summary(draft)
_pause("Press Enter to save and exit...")
for field_name in type(config).model_fields:
setattr(config, field_name, getattr(draft, field_name))
return True
# --- Main Entry Point ---
def _has_unsaved_changes(original: Config, current: Config) -> bool:
"""Return True when the onboarding session has committed changes."""
return original.model_dump(by_alias=True) != current.model_dump(by_alias=True)
def _prompt_main_menu_exit(has_unsaved_changes: bool) -> str:
"""Resolve how to leave the main menu."""
if not has_unsaved_changes:
return "discard"
answer = _get_questionary().select(
"You have unsaved changes. What would you like to do?",
choices=[
"[S] Save and Exit",
"[X] Exit Without Saving",
"[R] Resume Editing",
],
default="[R] Resume Editing",
qmark=">",
).ask()
if answer == "[S] Save and Exit":
return "save"
if answer == "[X] Exit Without Saving":
return "discard"
return "resume"
def _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]:
"""Return the top-level choices, keeping save actions hidden until needed."""
choices = [
_QUICK_START_MENU_CHOICE,
"[A] Advanced Settings",
]
if has_unsaved_changes:
choices.extend(["[S] Save and Exit", "[X] Exit Without Saving"])
else:
choices.append("[X] Exit")
return choices
def _configure_advanced_settings(config: Config) -> None:
"""Show lower-frequency setup options behind one advanced menu."""
last_choice: str | None = None
choices = [
"[P] LLM Provider",
"[M] Model Presets",
"[C] Chat Channel",
"[H] Channel Common",
"[A] Agent Settings",
"[I] API Server",
"[G] Gateway",
"[T] Tools",
"[V] View Configuration Summary",
"<- Back",
]
while True:
try:
console.clear()
_show_section_header(
"Advanced Settings",
"Use these when the default API-key setup is not enough.",
)
answer = _select_with_back(
"What would you like to configure?",
choices,
default=last_choice,
)
except KeyboardInterrupt:
break
if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break
_advanced_dispatch: dict[str, Callable[[], None]] = {
"[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config),
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
"[I] API Server": lambda: _configure_general_settings(config, "API Server"),
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
"[V] View Configuration Summary": lambda: _show_summary(config),
}
action_fn = _advanced_dispatch.get(cast(str, answer))
if action_fn:
last_choice = cast(str, answer)
action_fn()
def run_onboard(initial_config: Config | None = None) -> OnboardResult:
"""Run the interactive onboarding questionnaire.
Args:
initial_config: Optional pre-loaded config to use as starting point.
If None, loads from config file or creates new default.
"""
_get_questionary()
if initial_config is not None:
base_config = initial_config.model_copy(deep=True)
else:
config_path = get_config_path()
if config_path.exists():
base_config = load_config()
else:
base_config = Config()
original_config = base_config.model_copy(deep=True)
config = base_config.model_copy(deep=True)
_sync_preset_cache(config)
while True:
console.clear()
_show_main_menu_header()
try:
answer = _select_with_back(
"What would you like to do?",
_get_main_menu_choices(_has_unsaved_changes(original_config, config)),
)
except KeyboardInterrupt:
answer = None
if answer is _BACK_PRESSED or answer is None:
action = _prompt_main_menu_exit(_has_unsaved_changes(original_config, config))
if action == "save":
return OnboardResult(config=config, should_save=True)
if action == "discard":
return OnboardResult(config=original_config, should_save=False)
continue
if answer == _QUICK_START_MENU_CHOICE:
if _configure_quick_start(config):
return OnboardResult(config=config, should_save=True)
continue
if answer == "[S] Save and Exit":
return OnboardResult(config=config, should_save=True)
if answer in {"[X] Exit", "[X] Exit Without Saving"}:
return OnboardResult(config=original_config, should_save=False)
if answer == "[A] Advanced Settings":
_configure_advanced_settings(config)
def run_quick_start_onboard(initial_config: Config) -> OnboardResult:
"""Run the compact provider + local WebUI setup path directly."""
_get_questionary()
draft = initial_config.model_copy(deep=True)
if _configure_quick_start(draft):
return OnboardResult(config=draft, should_save=True)
return OnboardResult(config=initial_config, should_save=False)