{"repo": "amb1t10us/tracemind", "files": {"nanobot/nanobot/cli/onboard.py": {"87ab4842c02a1341d9678a703b633523e09c99a5": {"t": 1785595174.5227964, "n": 71613, "h": "ab5d3973c3e2ec59", "c": "\"\"\"Interactive onboarding questionnaire for nanobot.\"\"\"\n\nimport asyncio\nimport json\nimport types\nfrom dataclasses import dataclass\nfrom functools import lru_cache\nfrom typing import Any, Literal, NamedTuple, get_args, get_origin\n\ntry:\n import questionary\nexcept ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps\n questionary = None\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.table import Table\n\nfrom nanobot.cli.models import (\n format_token_count,\n get_model_context_limit,\n get_model_suggestions,\n)\nfrom nanobot.config.loader import get_config_path, load_config\nfrom nanobot.config.schema import Config, ModelPresetConfig\n\nconsole = Console()\n\n\n@dataclass\nclass OnboardResult:\n \"\"\"Result of an onboarding session.\"\"\"\n\n config: Config\n should_save: bool\n\n\nclass _QuickStartProviderInfo(NamedTuple):\n \"\"\"Provider metadata used by the Quick Start flow.\"\"\"\n\n display_name: str\n is_local: bool\n default_api_base: str\n backend: str\n is_direct: bool\n\n\nclass _QuickStartEndpointChoice(NamedTuple):\n \"\"\"Provider endpoint option used by Quick Start.\"\"\"\n\n label: str\n api_base: str\n\n\n# --- Field Hints for Select Fields ---\n# Maps field names to (choices, hint_text)\n# To add a new select field with hints, add an entry:\n# \"field_name\": ([\"choice1\", \"choice2\", ...], \"hint text for the field\")\n_SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {\n \"reasoning_effort\": (\n [\"low\", \"medium\", \"high\"],\n \"low / medium / high - enables LLM thinking mode\",\n ),\n}\n\n# --- Key Bindings for Navigation ---\n\n_BACK_PRESSED = object() # Sentinel value for back navigation\n\n# Cache of model-preset names populated at runtime so that field handlers can\n# offer existing presets as choices (e.g. AgentDefaults.model_preset).\n_MODEL_PRESET_CACHE: set[str] = set()\n\n_QUICK_START_CUSTOM_PROVIDER_CHOICE = \"Other OpenAI-compatible\"\n\n_CLEAR_CHOICE = \"Clear value\"\n_QUICK_START_MENU_CHOICE = \"[Q] Quick Start\"\n_QUICK_START_STEPS = (\"Provider setup\", \"WebSocket channel\", \"Review\")\n_QUICK_START_ENDPOINT_CHOICES: dict[str, tuple[_QuickStartEndpointChoice, ...]] = {\n \"zhipu\": (\n _QuickStartEndpointChoice(\"Standard API\", \"https://open.bigmodel.cn/api/paas/v4\"),\n _QuickStartEndpointChoice(\"Coding Plan\", \"https://open.bigmodel.cn/api/coding/paas/v4\"),\n ),\n \"minimax\": (\n _QuickStartEndpointChoice(\"Global API\", \"https://api.minimax.io/v1\"),\n _QuickStartEndpointChoice(\"Mainland China Token Plan\", \"https://api.minimaxi.com/v1\"),\n ),\n \"minimax_anthropic\": (\n _QuickStartEndpointChoice(\"Global Anthropic API\", \"https://api.minimax.io/anthropic\"),\n _QuickStartEndpointChoice(\n \"Mainland China Anthropic Token Plan\",\n \"https://api.minimaxi.com/anthropic\",\n ),\n ),\n \"stepfun\": (\n _QuickStartEndpointChoice(\"Standard API\", \"https://api.stepfun.com/v1\"),\n _QuickStartEndpointChoice(\"Step Plan\", \"https://api.stepfun.ai/step_plan/v1\"),\n ),\n \"xiaomi_mimo\": (\n _QuickStartEndpointChoice(\"Standard API\", \"https://api.xiaomimimo.com/v1\"),\n _QuickStartEndpointChoice(\"Token Plan\", \"https://token-plan-sgp.xiaomimimo.com/v1\"),\n ),\n}\n\n# Low-contrast terminal palette inspired by JetBrains Darcula/Islands.\n_UI_ACCENT = \"#6B9BFA\"\n_UI_BORDER = \"#4E5254\"\n_UI_TEXT = \"#A9B7C6\"\n_UI_MUTED = \"#80868B\"\n_UI_SUCCESS = \"#6AAB73\"\n_PROMPT_ESCAPE_TIMEOUT_SECONDS = 0.05\n_CHANNEL_LOGIN_CHOICE = \"Login with QR/link\"\n_CHANNEL_ADVANCED_CHOICE = \"Edit advanced settings\"\n\n\ndef _get_questionary():\n \"\"\"Return questionary or raise a clear error when wizard deps are unavailable.\"\"\"\n if questionary is None:\n raise RuntimeError(\n \"Interactive onboarding requires the optional 'questionary' dependency. \"\n \"Install project dependencies and rerun with --wizard.\"\n )\n return questionary\n\n\ndef _select_with_back(\n prompt: str, choices: list[str], default: str | None = None\n) -> str | None | object:\n \"\"\"Select with Escape/Left arrow support for going back.\n\n Args:\n prompt: The prompt text to display.\n choices: List of choices to select from. Must not be empty.\n default: The default choice to pre-select. If not in choices, first item is used.\n\n Returns:\n _BACK_PRESSED sentinel if user pressed Escape or Left arrow\n The selected choice string if user confirmed\n None if user cancelled (Ctrl+C)\n \"\"\"\n import shutil\n\n from prompt_toolkit.application import Application\n from prompt_toolkit.key_binding import KeyBindings\n from prompt_toolkit.keys import Keys\n from prompt_toolkit.layout import Layout\n from prompt_toolkit.layout.containers import HSplit, Window\n from prompt_toolkit.layout.controls import FormattedTextControl\n from prompt_toolkit.styles import Style\n\n # Validate choices\n if not choices:\n logger.warning(\"Empty choices list provided to _select_with_back\")\n return None\n\n # Find default index\n selected_index = 0\n if default and default in choices:\n selected_index = choices.index(default)\n\n # State holder for the result\n state: dict[str, str | None | object] = {\"result\": None}\n terminal_lines = shutil.get_terminal_size((80, 24)).lines\n visible_count = min(len(choices), max(1, terminal_lines - 3))\n\n # Build menu items (uses closure over selected_index)\n def get_menu_text():\n items = []\n start, end = _choice_viewport(selected_index, len(choices), visible_count)\n for i in range(start, end):\n choice = choices[i]\n if i == selected_index:\n items.append((\"class:selected\", f\"> {choice}\\n\"))\n else:\n items.append((\"\", f\" {choice}\\n\"))\n return items\n\n # Create layout\n menu_control = FormattedTextControl(get_menu_text, show_cursor=False)\n menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)\n\n def get_prompt_text():\n suffix = f\" ({selected_index + 1}/{len(choices)})\" if len(choices) > visible_count else \"\"\n return [(\"class:question\", f\"{prompt}{suffix}\")]\n\n prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False)\n prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)\n\n layout = Layout(HSplit([prompt_window, menu_window]))\n\n # Key bindings\n bindings = KeyBindings()\n\n @bindings.add(Keys.Up)\n def _up(event):\n nonlocal selected_index\n selected_index = (selected_index - 1) % len(choices)\n event.app.invalidate()\n\n @bindings.add(Keys.Down)\n def _down(event):\n nonlocal selected_index\n selected_index = (selected_index + 1) % len(choices)\n event.app.invalidate()\n\n @bindings.add(Keys.Enter)\n def _enter(event):\n state[\"result\"] = choices[selected_index]\n event.app.exit()\n\n @bindings.add(\"escape\")\n def _escape(event):\n state[\"result\"] = _BACK_PRESSED\n event.app.exit()\n\n @bindings.add(Keys.Left)\n def _left(event):\n state[\"result\"] = _BACK_PRESSED\n event.app.exit()\n\n @bindings.add(Keys.ControlC)\n def _ctrl_c(event):\n state[\"result\"] = None\n event.app.exit()\n\n # Style\n style = Style.from_dict({\n \"selected\": f\"fg:{_UI_ACCENT} bold\",\n \"question\": f\"fg:{_UI_TEXT}\",\n })\n\n app = Application(layout=layout, key_bindings=bindings, style=style)\n app.ttimeoutlen = 0.05\n app.timeoutlen = 0.05\n try:\n app.run()\n except Exception:\n logger.exception(\"Error in select prompt\")\n return None\n\n return state[\"result\"]\n\n\ndef _choice_viewport(selected_index: int, total: int, visible_count: int) -> tuple[int, int]:\n \"\"\"Return the visible slice for a long terminal menu.\"\"\"\n if total <= 0:\n return 0, 0\n visible_count = max(1, min(visible_count, total))\n selected_index = max(0, min(selected_index, total - 1))\n half = visible_count // 2\n start = selected_index - half\n start = max(0, min(start, total - visible_count))\n return start, start + visible_count\n\n# --- Type Introspection ---\n\n\nclass FieldTypeInfo(NamedTuple):\n \"\"\"Result of field type introspection.\"\"\"\n\n type_name: str\n inner_type: Any\n\n\ndef _get_field_type_info(field_info) -> FieldTypeInfo:\n \"\"\"Extract field type info from Pydantic field.\"\"\"\n annotation = field_info.annotation\n if annotation is None:\n return FieldTypeInfo(\"str\", None)\n\n origin = get_origin(annotation)\n args = get_args(annotation)\n\n if origin is types.UnionType:\n non_none_args = [a for a in args if a is not type(None)]\n if len(non_none_args) == 1:\n annotation = non_none_args[0]\n origin = get_origin(annotation)\n args = get_args(annotation)\n\n _simple_types: dict[type, str] = {bool: \"bool\", int: \"int\", float: \"float\"}\n\n if origin is list or (hasattr(origin, \"__name__\") and origin.__name__ == \"List\"):\n return FieldTypeInfo(\"list\", args[0] if args else str)\n if origin is dict or (hasattr(origin, \"__name__\") and origin.__name__ == \"Dict\"):\n return FieldTypeInfo(\"dict\", None)\n for py_type, name in _simple_types.items():\n if annotation is py_type:\n return FieldTypeInfo(name, None)\n if isinstance(annotation, type) and issubclass(annotation, BaseModel):\n return FieldTypeInfo(\"model\", annotation)\n if origin is Literal:\n return FieldTypeInfo(\"literal\", list(args))\n return FieldTypeInfo(\"str\", None)\n\n\ndef _get_field_display_name(field_key: str, field_info) -> str:\n \"\"\"Get display name for a field.\"\"\"\n if field_info and field_info.description:\n return field_info.description\n name = field_key\n suffix_map = {\n \"_s\": \" seconds\",\n \"_ms\": \" ms\",\n \"_url\": \" URL\",\n \"_path\": \" Path\",\n \"_id\": \" ID\",\n \"_key\": \" Key\",\n \"_token\": \" Token\",\n }\n for suffix, replacement in suffix_map.items():\n if name.endswith(suffix):\n name = name[: -len(suffix)] + replacement\n break\n return name.replace(\"_\", \" \").title()\n\n\n# --- Sensitive Field Masking ---\n\n_SENSITIVE_KEYWORDS = frozenset({\"api_key\", \"token\", \"secret\", \"password\", \"credentials\"})\n\n\ndef _is_sensitive_field(field_name: str) -> bool:\n \"\"\"Check if a field name indicates sensitive content.\"\"\"\n return any(kw in field_name.lower() for kw in _SENSITIVE_KEYWORDS)\n\n\ndef _mask_value(value: str) -> str:\n \"\"\"Mask a sensitive value, showing only the last 4 characters.\"\"\"\n if len(value) <= 4:\n return \"****\"\n return \"*\" * (len(value) - 4) + value[-4:]\n\n\n# --- Value Formatting ---\n\n\ndef _format_value(value: Any, rich: bool = True, field_name: str = \"\") -> str:\n \"\"\"Single recursive entry point for safe value display. Handles any depth.\"\"\"\n if value is None or value == \"\" or value == {} or value == []:\n return \"[dim]not set[/dim]\" if rich else \"[not set]\"\n if _is_sensitive_field(field_name) and isinstance(value, str):\n masked = _mask_value(value)\n return f\"[dim]{masked}[/dim]\" if rich else masked\n if isinstance(value, BaseModel):\n parts = []\n for fname, _finfo in type(value).model_fields.items():\n fval = getattr(value, fname, None)\n formatted = _format_value(fval, rich=False, field_name=fname)\n if formatted != \"[not set]\":\n parts.append(f\"{fname}={formatted}\")\n return \", \".join(parts) if parts else (\"[dim]not set[/dim]\" if rich else \"[not set]\")\n if isinstance(value, list):\n return \", \".join(str(v) for v in value)\n if isinstance(value, dict):\n # Handle dicts containing BaseModel instances\n parts = []\n for k, v in value.items():\n formatted = _format_value(v, rich=False, field_name=str(k))\n parts.append(f\"{k}: {formatted}\")\n return \", \".join(parts) if parts else (\"[dim]not set[/dim]\" if rich else \"[not set]\")\n return str(value)\n\n\ndef _format_value_for_input(value: Any, field_type: str) -> str:\n \"\"\"Format a value for use as input default.\"\"\"\n if value is None or value == \"\":\n return \"\"\n if field_type == \"list\" and isinstance(value, list):\n return \",\".join(str(v) for v in value)\n if field_type == \"dict\" and isinstance(value, dict):\n return json.dumps(value)\n return str(value)\n\n\ndef _validate_field_constraint(value: Any, field_info) -> str | None:\n \"\"\"Validate a value against Pydantic Field constraints.\n\n Returns an error message string if validation fails, None if valid.\n Uses attribute-based detection to handle Pydantic v2 internal types.\n \"\"\"\n if field_info is None or not hasattr(field_info, \"metadata\"):\n return None\n\n for m in field_info.metadata:\n if hasattr(m, \"ge\") and isinstance(value, (int, float)):\n if value < m.ge:\n return f\"Value must be >= {m.ge}\"\n if hasattr(m, \"gt\") and isinstance(value, (int, float)):\n if value <= m.gt:\n return f\"Value must be > {m.gt}\"\n if hasattr(m, \"le\") and isinstance(value, (int, float)):\n if value > m.le:\n return f\"Value must be <= {m.le}\"\n if hasattr(m, \"lt\") and isinstance(value, (int, float)):\n if value >= m.lt:\n return f\"Value must be < {m.lt}\"\n if hasattr(m, \"min_length\") and hasattr(value, \"__len__\"):\n if len(value) < m.min_length:\n return f\"Length must be >= {m.min_length}\"\n if hasattr(m, \"max_length\") and hasattr(value, \"__len__\"):\n if len(value) > m.max_length:\n return f\"Length must be <= {m.max_length}\"\n\n return None\n\n\ndef _get_constraint_hint(field_info) -> str:\n \"\"\"Derive a human-readable constraint hint from field metadata.\n\n Returns a string like \" - 0-10\" or \" - >= 0\" to append to field display names.\n \"\"\"\n if field_info is None or not hasattr(field_info, \"metadata\"):\n return \"\"\n\n ge_val = None\n le_val = None\n for m in field_info.metadata:\n if hasattr(m, \"ge\"):\n ge_val = m.ge\n if hasattr(m, \"le\"):\n le_val = m.le\n\n if ge_val is not None and le_val is not None:\n return f\" - {ge_val}-{le_val}\"\n if ge_val is not None:\n return f\" - >= {ge_val}\"\n if le_val is not None:\n return f\" - <= {le_val}\"\n return \"\"\n\n\n# --- Rich UI Components ---\n\n\ndef _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None:\n \"\"\"Display current configuration as a rich table.\"\"\"\n table = Table(show_header=False, box=None, padding=(0, 2))\n table.add_column(\"Field\", style=_UI_ACCENT)\n table.add_column(\"Value\")\n\n for fname, field_info in fields:\n value = getattr(model, fname, None)\n display = _get_field_display_name(fname, field_info)\n formatted = _format_value(value, rich=True, field_name=fname)\n table.add_row(display, formatted)\n\n console.print(Panel(table, title=f\"[bold {_UI_TEXT}]{display_name}[/]\", border_style=_UI_BORDER))\n\n\ndef _show_main_menu_header() -> None:\n \"\"\"Display the main menu header.\"\"\"\n from nanobot import __logo__, __version__\n\n console.print()\n body = Table.grid(expand=True)\n body.add_column(ratio=1)\n body.add_row(f\"{__logo__} [bold {_UI_TEXT}]nanobot[/] [{_UI_MUTED}]v{__version__}[/]\")\n body.add_row(f\"[{_UI_ACCENT}]Quick Start asks for the provider, credentials, and model.[/]\")\n body.add_row(\n f\"[{_UI_MUTED}]Use Advanced later for chat apps, tools, or provider-specific details.[/]\"\n )\n console.print(\n Panel(\n body,\n title=f\"[bold {_UI_TEXT}]Setup Wizard[/]\",\n border_style=_UI_BORDER,\n padding=(1, 2),\n )\n )\n console.print()\n\n\ndef _show_section_header(title: str, subtitle: str = \"\") -> None:\n \"\"\"Display a section header.\"\"\"\n console.print()\n if subtitle:\n console.print(\n Panel(\n f\"[{_UI_MUTED}]{subtitle}[/]\",\n title=f\"[bold {_UI_TEXT}]{title}[/]\",\n border_style=_UI_BORDER,\n padding=(1, 2),\n )\n )\n else:\n console.print(Panel(\"\", title=f\"[bold {_UI_TEXT}]{title}[/]\", border_style=_UI_BORDER))\n\n\n# --- Input Handlers ---\n\n\ndef _input_bool(display_name: str, current: bool | None) -> bool | None:\n \"\"\"Get boolean input via confirm dialog.\"\"\"\n return _get_questionary().confirm(\n display_name,\n default=bool(current) if current is not None else False,\n ).ask()\n\n\ndef _input_back_key_bindings():\n \"\"\"Return key bindings that make Escape behave like a local back action.\"\"\"\n from prompt_toolkit.key_binding import KeyBindings\n\n bindings = KeyBindings()\n\n @bindings.add(\"escape\")\n def _escape(event):\n event.app.exit(result=_BACK_PRESSED)\n\n return bindings\n\n\ndef _ask_prompt(prompt):\n \"\"\"Ask a questionary prompt with responsive Escape handling.\"\"\"\n app = getattr(prompt, \"application\", None)\n if app is not None:\n if hasattr(app, \"ttimeoutlen\"):\n app.ttimeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS\n if hasattr(app, \"timeoutlen\"):\n app.timeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS\n return prompt.ask()\n\n\ndef _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:\n \"\"\"Get text input and parse based on field type.\"\"\"\n default = _format_value_for_input(current, field_type)\n\n value = _ask_prompt(\n _get_questionary().text(\n f\"{display_name}:\",\n default=default,\n key_bindings=_input_back_key_bindings(),\n )\n )\n\n if value is _BACK_PRESSED or value is None:\n return None if value is None else _BACK_PRESSED\n\n if field_type == \"int\":\n try:\n parsed = int(value)\n except ValueError:\n console.print(\"[yellow]! Invalid number format, value not saved[/yellow]\")\n return None\n if field_info:\n error = _validate_field_constraint(parsed, field_info)\n if error:\n console.print(f\"[yellow]! {error}, value not saved[/yellow]\")\n return None\n return parsed\n elif field_type == \"float\":\n try:\n parsed = float(value)\n except ValueError:\n console.print(\"[yellow]! Invalid number format, value not saved[/yellow]\")\n return None\n if field_info:\n error = _validate_field_constraint(parsed, field_info)\n if error:\n console.print(f\"[yellow]! {error}, value not saved[/yellow]\")\n return None\n return parsed\n elif field_type == \"list\":\n return [v.strip() for v in value.split(\",\") if v.strip()]\n elif field_type == \"dict\":\n try:\n return json.loads(value)\n except json.JSONDecodeError:\n console.print(\"[yellow]! Invalid JSON format, value not saved[/yellow]\")\n return None\n\n return value\n\n\ndef _input_secret(display_name: str) -> str | None | object:\n \"\"\"Get a secret value without echoing it when questionary supports password input.\"\"\"\n prompt_factory = getattr(_get_questionary(), \"password\", None)\n if prompt_factory is None:\n prompt_factory = _get_questionary().text\n value = _ask_prompt(prompt_factory(f\"{display_name}:\", key_bindings=_input_back_key_bindings()))\n if value is _BACK_PRESSED or value is None:\n return None if value is None else _BACK_PRESSED\n return str(value).strip()\n\n\ndef _input_with_existing(\n display_name: str, current: Any, field_type: str, field_info=None\n) -> Any:\n \"\"\"Handle input with 'keep existing' option for non-empty values.\"\"\"\n has_existing = current is not None and current != \"\" and current != {} and current != []\n\n if has_existing and not isinstance(current, list):\n choice = _get_questionary().select(\n display_name,\n choices=[\"Enter new value\", \"Keep existing value\"],\n default=\"Keep existing value\",\n ).ask()\n if choice == \"Keep existing value\" or choice is None:\n return None\n\n return _input_text(display_name, current, field_type, field_info=field_info)\n\n\n# --- Pydantic Model Configuration ---\n\n\ndef _get_current_provider(model: BaseModel) -> str:\n \"\"\"Get the current provider setting from a model (if available).\"\"\"\n if hasattr(model, \"provider\"):\n return getattr(model, \"provider\", \"auto\") or \"auto\"\n return \"auto\"\n\n\ndef _input_model_with_autocomplete(\n display_name: str, current: Any, provider: str\n) -> str | None | object:\n \"\"\"Get model input with autocomplete suggestions.\n\n \"\"\"\n from prompt_toolkit.completion import Completer, Completion\n\n default = str(current) if current else \"\"\n\n class DynamicModelCompleter(Completer):\n \"\"\"Completer that dynamically fetches model suggestions.\"\"\"\n\n def __init__(self, provider_name: str):\n self.provider = provider_name\n\n def get_completions(self, document, _complete_event):\n text = document.text_before_cursor\n suggestions = get_model_suggestions(text, provider=self.provider, limit=50)\n for model in suggestions:\n # Skip if model doesn't contain the typed text\n if text.lower() not in model.lower():\n continue\n yield Completion(\n model,\n start_position=-len(text),\n display=model,\n )\n\n value = _ask_prompt(\n _get_questionary().autocomplete(\n f\"{display_name}:\",\n choices=[\"\"], # Placeholder, actual completions from completer\n completer=DynamicModelCompleter(provider),\n default=default,\n key_bindings=_input_back_key_bindings(),\n qmark=\">\",\n )\n )\n\n if value is _BACK_PRESSED or value is None:\n return None if value is None else _BACK_PRESSED\n return value\n\n\ndef _input_context_window_with_recommendation(\n display_name: str, current: Any, model_obj: BaseModel\n) -> int | None | object:\n \"\"\"Get context window input with option to fetch recommended value.\"\"\"\n current_val = current if current else \"\"\n\n choices = [\"Enter new value\"]\n if current_val:\n choices.append(\"Keep existing value\")\n choices.append(\"[?] Get recommended value\")\n\n choice = _get_questionary().select(\n display_name,\n choices=choices,\n default=\"Enter new value\",\n ).ask()\n\n if choice is None:\n return None\n\n if choice == \"Keep existing value\":\n return None\n\n if choice == \"[?] Get recommended value\":\n # Get the model name from the model object\n model_name = getattr(model_obj, \"model\", None)\n if not model_name:\n console.print(\"[yellow]! Please configure the model field first[/yellow]\")\n return None\n\n provider = _get_current_provider(model_obj)\n context_limit = get_model_context_limit(model_name, provider)\n\n if context_limit:\n console.print(\n f\"[{_UI_SUCCESS}]+ Recommended context window: \"\n f\"{format_token_count(context_limit)} tokens[/]\"\n )\n return context_limit\n else:\n console.print(\"[yellow]! Could not fetch model info, please enter manually[/yellow]\")\n # Fall through to manual input\n\n # Manual input\n value = _get_questionary().text(\n f\"{display_name}:\",\n default=str(current_val) if current_val else \"\",\n key_bindings=_input_back_key_bindings(),\n ).ask()\n\n if value is _BACK_PRESSED:\n return _BACK_PRESSED\n if value is None or value == \"\":\n return None\n\n try:\n return int(value)\n except ValueError:\n console.print(\"[yellow]! Invalid number format, value not saved[/yellow]\")\n return None\n\n\ndef _handle_model_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle the 'model' field with autocomplete and context-window auto-fill.\"\"\"\n provider = _get_current_provider(working_model)\n new_value = _input_model_with_autocomplete(field_display, current_value, provider)\n if new_value is _BACK_PRESSED:\n return\n if new_value is not None and new_value != current_value:\n setattr(working_model, field_name, new_value)\n _try_auto_fill_context_window(working_model, new_value)\n\n\ndef _handle_context_window_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle context_window_tokens with recommendation lookup.\"\"\"\n new_value = _input_context_window_with_recommendation(\n field_display, current_value, working_model\n )\n if new_value is _BACK_PRESSED:\n return\n if new_value is not None:\n setattr(working_model, field_name, new_value)\n\n\ndef _handle_model_preset_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle the 'model_preset' field with a list of existing presets.\"\"\"\n preset_names = sorted(_MODEL_PRESET_CACHE)\n choices = [_CLEAR_CHOICE] + preset_names\n default_choice = str(current_value) if current_value else _CLEAR_CHOICE\n new_value = _select_with_back(field_display, choices, default=default_choice)\n if new_value is _BACK_PRESSED:\n return\n if new_value == _CLEAR_CHOICE:\n setattr(working_model, field_name, None)\n elif new_value is not None:\n setattr(working_model, field_name, new_value)\n\n\ndef _set_field_from_choices(\n working_model: BaseModel, field_name: str, field_display: str,\n choices: list[str], default_choice: str\n) -> None:\n \"\"\"Prompt to pick one of ``choices`` and set the field (no-op on back/cancel).\"\"\"\n new_value = _select_with_back(field_display, choices, default=default_choice)\n if new_value is _BACK_PRESSED:\n return\n if new_value is not None:\n setattr(working_model, field_name, new_value)\n\n\ndef _handle_provider_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle the 'provider' field with a list of registered LLM providers.\"\"\"\n choices = [\"auto\"] + sorted(_get_provider_names().keys())\n default_choice = str(current_value) if current_value else \"auto\"\n _set_field_from_choices(working_model, field_name, field_display, choices, default_choice)\n\n\ndef _handle_fallback_models_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle the 'fallback_models' field with preset-aware list management.\"\"\"\n from nanobot.config.schema import InlineFallbackConfig\n\n items: list[Any] = list(current_value) if isinstance(current_value, list) else []\n preset_names = sorted(_MODEL_PRESET_CACHE)\n\n while True:\n console.clear()\n console.print(f\"[bold]{field_display}[/bold]\")\n if items:\n for idx, item in enumerate(items, 1):\n if isinstance(item, InlineFallbackConfig):\n console.print(f\" {idx}. {item.model} - {item.provider} inline\")\n else:\n console.print(f\" {idx}. {item}\")\n else:\n console.print(\" [dim]empty[/dim]\")\n console.print()\n\n choices = [\"[+] Add preset\"]\n if items:\n choices.append(\"[-] Remove last\")\n choices.append(\"[X] Clear all\")\n choices.append(\"[Done]\")\n choices.append(\"<- Back\")\n\n answer = _get_questionary().select(\n \"Manage fallback models:\",\n choices=choices,\n qmark=\">\",\n ).ask()\n\n if answer is None or answer == \"<- Back\":\n return\n if answer == \"[Done]\":\n setattr(working_model, field_name, items)\n return\n if answer == \"[+] Add preset\":\n if not preset_names:\n console.print(\"[yellow]! No presets defined yet.[/yellow]\")\n _get_questionary().press_any_key_to_continue().ask()\n continue\n add_choices = [p for p in preset_names if p not in items]\n if not add_choices:\n console.print(\"[yellow]! All presets already added.[/yellow]\")\n _get_questionary().press_any_key_to_continue().ask()\n continue\n picked = _select_with_back(\"Select preset:\", add_choices)\n if picked is _BACK_PRESSED or picked is None:\n continue\n items.append(picked)\n elif answer == \"[-] Remove last\" and items:\n items.pop()\n elif answer == \"[X] Clear all\" and items:\n items.clear()\n\n\ndef _handle_search_provider_field(\n working_model: BaseModel, field_name: str, field_display: str, current_value: Any\n) -> None:\n \"\"\"Handle the web-search 'provider' field with the search-engine list.\"\"\"\n from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS\n\n choices = [opt[\"name\"] for opt in SEARCH_PROVIDER_OPTIONS]\n default_choice = current_value if current_value in choices else choices[0]\n _set_field_from_choices(working_model, field_name, field_display, choices, default_choice)\n\n\n_FIELD_HANDLERS: dict[str, Any] = {\n \"model\": _handle_model_field,\n \"context_window_tokens\": _handle_context_window_field,\n \"model_preset\": _handle_model_preset_field,\n \"provider\": _handle_provider_field,\n \"fallback_models\": _handle_fallback_models_field,\n}\n\n\ndef _resolve_field_handler(model: BaseModel, field_name: str) -> Any:\n \"\"\"Resolve the handler for a field. WebSearchConfig shares the bare \"provider\"\n name with LLM configs but needs the search-engine picker, not the LLM list.\"\"\"\n if field_name == \"provider\":\n from nanobot.agent.tools.web import WebSearchConfig\n if isinstance(model, WebSearchConfig):\n return _handle_search_provider_field\n return _FIELD_HANDLERS.get(field_name)\n\n\ndef _is_str_or_none(annotation: Any) -> bool:\n \"\"\"Check whether a field annotation is ``str | None`` (or ``Optional[str]``).\"\"\"\n origin = get_origin(annotation)\n if origin is None:\n return False\n args = get_args(annotation)\n return str in args and type(None) in args\n\n\ndef _configure_pydantic_model(\n model: BaseModel,\n display_name: str,\n *,\n skip_fields: set[str] | None = None,\n) -> BaseModel | None:\n \"\"\"Configure a Pydantic model interactively.\n\n Returns the updated model when the user selects \"Done\" or navigates back.\n Cancel actions discard the section draft.\n \"\"\"\n skip_fields = skip_fields or set()\n working_model = model.model_copy(deep=True)\n\n fields = [\n (name, info)\n for name, info in type(working_model).model_fields.items()\n if name not in skip_fields\n ]\n if not fields:\n console.print(f\"[dim]{display_name}: No configurable fields[/dim]\")\n return working_model\n\n def get_choices() -> list[str]:\n items = []\n for fname, finfo in fields:\n value = getattr(working_model, fname, None)\n display = _get_field_display_name(fname, finfo)\n formatted = _format_value(value, rich=False, field_name=fname)\n items.append(f\"{display}: {formatted}\")\n return items + [\"[Done]\"]\n\n last_field_name: str | None = None\n while True:\n console.clear()\n _show_config_panel(display_name, working_model, fields)\n choices = get_choices()\n default_choice = None\n if last_field_name:\n for idx, (fname, _) in enumerate(fields):\n if fname == last_field_name:\n default_choice = choices[idx]\n break\n answer = _select_with_back(\n \"Select field to configure:\", choices, default=default_choice\n )\n\n if answer is _BACK_PRESSED:\n return working_model\n if answer is None:\n return None\n if answer == \"[Done]\":\n return working_model\n\n field_idx = next((i for i, c in enumerate(choices) if c == answer), -1)\n if field_idx < 0 or field_idx >= len(fields):\n return None\n\n last_field_name = fields[field_idx][0]\n\n field_name, field_info = fields[field_idx]\n current_value = getattr(working_model, field_name, None)\n ftype = _get_field_type_info(field_info)\n field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)\n\n # Nested Pydantic model - recurse\n if ftype.type_name == \"model\":\n nested = current_value\n created = nested is None\n if nested is None and ftype.inner_type:\n nested = ftype.inner_type()\n if nested and isinstance(nested, BaseModel):\n updated = _configure_pydantic_model(nested, field_display)\n if updated is not None:\n setattr(working_model, field_name, updated)\n elif created:\n setattr(working_model, field_name, None)\n continue\n\n # Registered special-field handlers\n handler = _resolve_field_handler(working_model, field_name)\n if handler:\n handler(working_model, field_name, field_display, current_value)\n continue\n\n # Select fields with hints (e.g. reasoning_effort)\n if field_name in _SELECT_FIELD_HINTS:\n choices_list, hint = _SELECT_FIELD_HINTS[field_name]\n select_choices = choices_list + [_CLEAR_CHOICE]\n console.print(f\"[dim] Hint: {hint}[/dim]\")\n new_value = _select_with_back(\n field_display, select_choices, default=current_value or select_choices[0]\n )\n if new_value is _BACK_PRESSED:\n continue\n if new_value == _CLEAR_CHOICE:\n setattr(working_model, field_name, None)\n elif new_value is not None:\n setattr(working_model, field_name, new_value)\n continue\n\n # Generic field input\n if ftype.type_name == \"literal\" and ftype.inner_type:\n select_choices = [str(v) for v in ftype.inner_type]\n default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]\n new_value = _select_with_back(field_display, select_choices, default=default_choice)\n if new_value is _BACK_PRESSED:\n continue\n if new_value is not None:\n setattr(working_model, field_name, new_value)\n continue\n if ftype.type_name == \"bool\":\n new_value = _input_bool(field_display, current_value)\n else:\n new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)\n if new_value is _BACK_PRESSED:\n continue\n if new_value is not None:\n # Normalize empty string to None for optional string fields so that\n # clearing an api_key / api_base actually removes the value.\n if new_value == \"\" and _is_str_or_none(field_info.annotation):\n new_value = None\n setattr(working_model, field_name, new_value)\n\n\ndef _try_auto_fill_context_window(model: BaseModel, new_model_name: str) -> None:\n \"\"\"Try to auto-fill context_window_tokens if it's at default value.\n\n Note:\n This function imports AgentDefaults from nanobot.config.schema to get\n the default context_window_tokens value. If the schema changes, this\n coupling needs to be updated accordingly.\n \"\"\"\n # Check if context_window_tokens field exists\n if not hasattr(model, \"context_window_tokens\"):\n return\n\n current_context = getattr(model, \"context_window_tokens\", None)\n\n # Check if current value is the default\n # We only auto-fill if the user hasn't changed it from default\n from nanobot.config.schema import AgentDefaults\n\n default_context = AgentDefaults.model_fields[\"context_window_tokens\"].default\n\n if current_context != default_context:\n return # User has customized it, don't override\n\n provider = _get_current_provider(model)\n context_limit = get_model_context_limit(new_model_name, provider)\n\n if context_limit:\n setattr(model, \"context_window_tokens\", context_limit)\n console.print(\n f\"[{_UI_SUCCESS}]+ Auto-filled context window: \"\n f\"{format_token_count(context_limit)} tokens[/]\"\n )\n else:\n console.print(\"[dim]Could not auto-fill context window - model not in database[/dim]\")\n\n\n# --- Model Preset Configuration ---\n\n\ndef _sync_preset_cache(config: Config) -> None:\n \"\"\"Synchronise the module-level preset name cache from config.\"\"\"\n _MODEL_PRESET_CACHE.clear()\n _MODEL_PRESET_CACHE.update(config.model_presets.keys())\n\n\ndef _configure_model_presets(config: Config) -> None:\n \"\"\"Configure model presets (CRUD).\"\"\"\n _sync_preset_cache(config)\n\n def get_preset_choices() -> tuple[list[str], dict[str, str]]:\n choices: list[str] = []\n choice_to_preset: dict[str, str] = {}\n for name, preset in config.model_presets.items():\n choice = f\"{name} - {preset.model}\"\n choices.append(choice)\n choice_to_preset[choice] = name\n choices.append(\"[+] Add new preset\")\n choices.append(\"<- Back\")\n return choices, choice_to_preset\n\n last_preset_name: str | None = None\n while True:\n try:\n console.clear()\n _show_section_header(\n \"Model Presets\",\n \"Create, edit or delete named model presets for quick switching\",\n )\n choices, choice_to_preset = get_preset_choices()\n default_choice = None\n if last_preset_name:\n for choice, name in choice_to_preset.items():\n if name == last_preset_name:\n default_choice = choice\n break\n answer = _select_with_back(\n \"Select preset:\", choices, default=default_choice\n )\n\n if answer is _BACK_PRESSED or answer is None or answer == \"<- Back\":\n break\n\n assert isinstance(answer, str)\n\n if answer == \"[+] Add new preset\":\n name_input = _get_questionary().text(\n \"Preset name:\",\n validate=lambda t: True if t and t.strip() else \"Name cannot be empty\",\n ).ask()\n if not name_input:\n continue\n name = name_input.strip()\n if name in config.model_presets:\n console.print(f\"[yellow]! Preset '{name}' already exists[/yellow]\")\n _pause()\n continue\n if name == \"default\":\n console.print(\n \"[yellow]! 'default' is reserved; it is generated from Agent Settings[/yellow]\"\n )\n _pause()\n continue\n new_preset = ModelPresetConfig(model=\"\")\n updated = _configure_pydantic_model(new_preset, f\"New Preset: {name}\")\n if updated is not None:\n config.model_presets[name] = updated\n _sync_preset_cache(config)\n last_preset_name = name\n continue\n\n # Editing / deleting an existing preset\n preset_name = choice_to_preset.get(answer)\n if preset_name is None:\n continue\n preset = config.model_presets.get(preset_name)\n if preset is None:\n continue\n\n last_preset_name = preset_name\n\n choices = [\"Edit\", \"Cancel\"]\n if preset_name != \"default\":\n choices.insert(1, \"Delete\")\n action = _select_with_back(\n f\"Preset: {preset_name}\",\n choices,\n default=\"Edit\",\n )\n if action is _BACK_PRESSED or action == \"Cancel\" or action is None:\n continue\n\n if action == \"Delete\":\n confirm = _get_questionary().confirm(\n f\"Delete preset '{preset_name}'?\",\n default=False,\n ).ask()\n if confirm:\n del config.model_presets[preset_name]\n _sync_preset_cache(config)\n last_preset_name = None\n continue\n\n if action == \"Edit\":\n updated = _configure_pydantic_model(preset, f\"Edit Preset: {preset_name}\")\n if updated is not None:\n config.model_presets[preset_name] = updated\n _sync_preset_cache(config)\n\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Returning to main menu...[/dim]\")\n break\n\n\n# --- Provider Configuration ---\n\n\n@lru_cache(maxsize=1)\ndef _get_provider_info() -> dict[str, tuple[str, bool, bool, str]]:\n \"\"\"Get provider info from registry (cached).\"\"\"\n from nanobot.providers.registry import PROVIDERS\n\n return {\n spec.name: (\n spec.display_name or spec.name,\n spec.is_gateway,\n spec.is_local,\n spec.default_api_base,\n )\n for spec in PROVIDERS\n if not spec.is_oauth\n }\n\n\ndef _get_provider_names() -> dict[str, str]:\n \"\"\"Get provider display names.\"\"\"\n info = _get_provider_info()\n return {name: data[0] for name, data in info.items() if name}\n\n\ndef _configure_provider(config: Config, provider_name: str) -> None:\n \"\"\"Configure a single LLM provider.\"\"\"\n provider_config = getattr(config.providers, provider_name, None)\n if provider_config is None:\n console.print(f\"[red]Unknown provider: {provider_name}[/red]\")\n return\n\n display_name = _get_provider_names().get(provider_name, provider_name)\n info = _get_provider_info()\n default_api_base = info.get(provider_name, (None, None, None, None))[3]\n\n if default_api_base and not provider_config.api_base:\n provider_config.api_base = default_api_base\n\n updated_provider = _configure_pydantic_model(\n provider_config,\n display_name,\n )\n if updated_provider is not None:\n setattr(config.providers, provider_name, updated_provider)\n\n\ndef _configure_providers(config: Config) -> None:\n \"\"\"Configure LLM providers.\"\"\"\n\n def get_provider_choices() -> list[str]:\n \"\"\"Build provider choices with config status indicators.\"\"\"\n choices = []\n for name, display in _get_provider_names().items():\n provider = getattr(config.providers, name, None)\n if provider and provider.api_key:\n choices.append(f\"{display} *\")\n else:\n choices.append(display)\n return choices + [\"<- Back\"]\n\n last_provider_key: str | None = None\n while True:\n try:\n console.clear()\n _show_section_header(\"LLM Providers\", \"Select a provider to configure API key and endpoint\")\n choices = get_provider_choices()\n default_choice = None\n if last_provider_key:\n display = _get_provider_names().get(last_provider_key)\n if display:\n for c in choices:\n if c.replace(\" *\", \"\") == display:\n default_choice = c\n break\n answer = _select_with_back(\n \"Select provider:\", choices, default=default_choice\n )\n\n if answer is _BACK_PRESSED or answer is None or answer == \"<- Back\":\n break\n\n # Type guard: answer is now guaranteed to be a string\n assert isinstance(answer, str)\n # Extract provider name from choice (remove \" *\" suffix if present)\n provider_name = answer.replace(\" *\", \"\")\n # Find the actual provider key from display names\n for name, display in _get_provider_names().items():\n if display == provider_name:\n last_provider_key = name\n _configure_provider(config, name)\n break\n\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Returning to main menu...[/dim]\")\n break\n\n\n# --- Channel Configuration ---\n\n\n@lru_cache(maxsize=1)\ndef _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]:\n \"\"\"Get channel info (display name + config class) from channel modules.\"\"\"\n import importlib\n\n from nanobot.channels.registry import discover_all\n\n result: dict[str, tuple[str, type[BaseModel]]] = {}\n for name, channel_cls in discover_all().items():\n try:\n mod = importlib.import_module(f\"nanobot.channels.{name}\")\n config_name = channel_cls.__name__.replace(\"Channel\", \"Config\")\n config_cls = getattr(mod, config_name, None)\n if config_cls and isinstance(config_cls, type) and issubclass(config_cls, BaseModel):\n display_name = getattr(channel_cls, \"display_name\", name.capitalize())\n result[name] = (display_name, config_cls)\n except Exception:\n logger.warning(\"Failed to load channel module: {}\", name)\n return result\n\n\ndef _get_channel_names() -> dict[str, str]:\n \"\"\"Get channel display names.\"\"\"\n return {name: info[0] for name, info in _get_channel_info().items()}\n\n\ndef _get_channel_config_class(channel: str) -> type[BaseModel] | None:\n \"\"\"Get channel config class.\"\"\"\n entry = _get_channel_info().get(channel)\n return entry[1] if entry else None\n\n\ndef _get_channel_class(channel: str) -> type[Any] | None:\n \"\"\"Get channel implementation class.\"\"\"\n from nanobot.channels.registry import discover_all\n\n return discover_all().get(channel)\n\n\ndef _channel_supports_login(channel_cls: type[Any] | None) -> bool:\n \"\"\"Return True when a channel overrides BaseChannel.login.\"\"\"\n if channel_cls is None:\n return False\n from nanobot.channels.base import BaseChannel\n\n return getattr(channel_cls, \"login\", None) is not BaseChannel.login\n\n\ndef _run_channel_login(\n config: Config,\n channel_name: str,\n model: BaseModel,\n display_name: str,\n) -> bool:\n \"\"\"Run a channel's interactive login and enable it only on success.\"\"\"\n channel_cls = _get_channel_class(channel_name)\n if channel_cls is None:\n console.print(f\"[red]Unknown channel: {channel_name}[/red]\")\n return False\n if not _channel_supports_login(channel_cls):\n return False\n\n if hasattr(model, \"enabled\"):\n setattr(model, \"enabled\", True)\n\n console.print(f\"[{_UI_ACCENT}]Starting {display_name} login...[/]\")\n try:\n channel = channel_cls(model, bus=None)\n success = asyncio.run(channel.login(force=False))\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Login cancelled.[/dim]\")\n return False\n except Exception as exc:\n logger.exception(\"{} login failed\", display_name)\n console.print(f\"[red]{display_name} login failed:[/red] {exc}\")\n return False\n\n if not success:\n console.print(f\"[yellow]! {display_name} login did not complete; channel was not enabled[/yellow]\")\n return False\n\n setattr(config.channels, channel_name, model.model_dump(by_alias=True, exclude_none=True))\n console.print(f\"[{_UI_SUCCESS}]{display_name} enabled[/]\")\n return True\n\n\ndef _configure_channel(config: Config, channel_name: str) -> None:\n \"\"\"Configure a single channel.\"\"\"\n channel_dict = getattr(config.channels, channel_name, None)\n if channel_dict is None:\n channel_dict = {}\n setattr(config.channels, channel_name, channel_dict)\n\n display_name = _get_channel_names().get(channel_name, channel_name)\n config_cls = _get_channel_config_class(channel_name)\n\n if config_cls is None:\n console.print(f\"[red]No configuration class found for {display_name}[/red]\")\n return\n\n model = config_cls.model_validate(channel_dict) if channel_dict else config_cls()\n\n channel_cls = _get_channel_class(channel_name)\n if _channel_supports_login(channel_cls):\n action = _select_with_back(\n f\"Configure {display_name}:\",\n [_CHANNEL_LOGIN_CHOICE, _CHANNEL_ADVANCED_CHOICE, \"<- Back\"],\n default=_CHANNEL_LOGIN_CHOICE,\n )\n if action is _BACK_PRESSED or action is None or action == \"<- Back\":\n return\n if action == _CHANNEL_LOGIN_CHOICE:\n _run_channel_login(config, channel_name, model, display_name)\n return\n\n updated_channel = _configure_pydantic_model(\n model,\n display_name,\n )\n if updated_channel is not None:\n new_dict = updated_channel.model_dump(by_alias=True, exclude_none=True)\n setattr(config.channels, channel_name, new_dict)\n\n\ndef _configure_channels(config: Config) -> None:\n \"\"\"Configure chat channels.\"\"\"\n channel_names = list(_get_channel_names().keys())\n choices = channel_names + [\"<- Back\"]\n\n last_choice: str | None = None\n while True:\n try:\n console.clear()\n _show_section_header(\"Chat Channels\", \"Select a channel to configure connection settings\")\n answer = _select_with_back(\n \"Select channel:\", choices, default=last_choice\n )\n\n if answer is _BACK_PRESSED or answer is None or answer == \"<- Back\":\n break\n\n # Type guard: answer is now guaranteed to be a string\n assert isinstance(answer, str)\n last_choice = answer\n _configure_channel(config, answer)\n except KeyboardInterrupt:\n console.print(\"\\n[dim]Returning to main menu...[/dim]\")\n break\n\n\n# --- General Settings ---\n\n_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {\n \"Agent Settings\": (\"Agent Defaults\", \"Configure default model, temperature, and behavior\", None),\n \"Channel Common\": (\"Channel Common\", \"Configure cross-channel behavior: progress, tool hints, retries\", None),\n \"API Server\": (\"API Server\", \"Configure OpenAI-compatible API endpoint\", None),\n \"Gateway\": (\"Gateway Settings\", \"Configure server host, port\", None),\n \"Tools\": (\"Tools Settings\", \"Configure web search, shell exec, and other tools\", {\"mcp_servers\"}),\n}\n\n_SETTINGS_GETTER = {\n \"Agent Settings\": lambda c: c.agents.defaults,\n \"Channel Common\": lambda c: c.channels,\n \"API Server\": lambda c: c.api,\n \"Gateway\": lambda c: c.gateway,\n \"Tools\": lambda c: c.tools,\n}\n\n_SETTINGS_SETTER = {\n \"Agent Settings\": lambda c, v: setattr(c.agents, \"defaults\", v),\n \"Channel Common\": lambda c, v: setattr(c, \"channels\", v),\n \"API Server\": lambda c, v: setattr(c, \"api\", v),\n \"Gateway\": lambda c, v: setattr(c, \"gateway\", v),\n \"Tools\": lambda c, v: setattr(c, \"tools\", v),\n}\n\n\ndef _configure_general_settings(config: Config, section: str) -> None:\n \"\"\"Configure a general settings section (header + model edit + writeback).\"\"\"\n meta = _SETTINGS_SECTIONS.get(section)\n if not meta:\n return\n display_name, subtitle, skip = meta\n model = _SETTINGS_GETTER[section](config)\n updated = _configure_pydantic_model(model, display_name, skip_fields=skip)\n if updated is not None:\n _SETTINGS_SETTER[section](config, updated)\n\n\n# --- Summary ---\n\n\ndef _summarize_model(obj: BaseModel) -> list[tuple[str, str]]:\n \"\"\"Recursively summarize a Pydantic model. Returns list of (field, value) tuples.\"\"\"\n items: list[tuple[str, str]] = []\n for field_name, field_info in type(obj).model_fields.items():\n value = getattr(obj, field_name, None)\n if value is None or value == \"\" or value == {} or value == []:\n continue\n display = _get_field_display_name(field_name, field_info)\n ftype = _get_field_type_info(field_info)\n if ftype.type_name == \"model\" and isinstance(value, BaseModel):\n for nested_field, nested_value in _summarize_model(value):\n items.append((f\"{display}.{nested_field}\", nested_value))\n continue\n formatted = _format_value(value, rich=False, field_name=field_name)\n if formatted != \"[not set]\":\n items.append((display, formatted))\n return items\n\n\ndef _print_summary_panel(rows: list[tuple[str, str]], title: str) -> None:\n \"\"\"Build a two-column summary panel and print it.\"\"\"\n if not rows:\n return\n table = Table(show_header=False, box=None, padding=(0, 2))\n table.add_column(\"Setting\", style=_UI_ACCENT)\n table.add_column(\"Value\")\n for field, value in rows:\n table.add_row(field, value)\n console.print(Panel(table, title=f\"[bold {_UI_TEXT}]{title}[/]\", border_style=_UI_BORDER))\n\n\ndef _show_summary(config: Config) -> None:\n \"\"\"Display configuration summary using rich.\"\"\"\n console.print()\n\n # Providers\n provider_rows = []\n for name, display in _get_provider_names().items():\n provider = getattr(config.providers, name, None)\n status = (\n f\"[{_UI_SUCCESS}]configured[/]\"\n if (provider and provider.api_key)\n else f\"[{_UI_MUTED}]not configured[/]\"\n )\n provider_rows.append((display, status))\n _print_summary_panel(provider_rows, \"LLM Providers\")\n\n # Channels\n channel_rows = []\n for name, display in _get_channel_names().items():\n channel = getattr(config.channels, name, None)\n if channel:\n enabled = (\n channel.get(\"enabled\", False)\n if isinstance(channel, dict)\n else getattr(channel, \"enabled\", False)\n )\n status = f\"[{_UI_SUCCESS}]enabled[/]\" if enabled else f\"[{_UI_MUTED}]disabled[/]\"\n else:\n status = f\"[{_UI_MUTED}]not configured[/]\"\n channel_rows.append((display, status))\n _print_summary_panel(channel_rows, \"Chat Channels\")\n\n # Model Presets\n preset_rows = []\n for name, preset in config.model_presets.items():\n preset_rows.append((name, f\"{preset.model} - ctx {preset.context_window_tokens}\"))\n _print_summary_panel(preset_rows, \"Model Presets\")\n\n # Settings sections\n for title, model in [\n (\"Agent Settings\", config.agents.defaults),\n (\"Channel Common\", config.channels),\n (\"API Server\", config.api),\n (\"Gateway\", config.gateway),\n (\"Tools\", config.tools),\n ]:\n _print_summary_panel(_summarize_model(model), title)\n\n _pause()\n\n\ndef _pause(message: str = \"Press Enter to continue...\") -> None:\n \"\"\"Pause for user acknowledgement before clearing the screen.\"\"\"\n _get_questionary().text(message, default=\"\").ask()\n\n\n# --- Quick Start ---\n\n\ndef _set_primary_quick_start_preset(config: Config, provider_name: str, model: str) -> None:\n \"\"\"Store the primary preset used by Quick Start.\"\"\"\n config.model_presets[\"primary\"] = ModelPresetConfig(\n label=\"Primary\",\n model=model,\n provider=provider_name,\n )\n config.agents.defaults.model_preset = \"primary\"\n _sync_preset_cache(config)\n\n\ndef _show_quick_start_progress(active_step: int) -> None:\n \"\"\"Render a compact step tracker for Quick Start.\"\"\"\n parts = []\n for idx, label in enumerate(_QUICK_START_STEPS, 1):\n if idx < active_step:\n parts.append(f\"[{_UI_SUCCESS}]{idx}. {label}[/]\")\n elif idx == active_step:\n parts.append(f\"[bold {_UI_ACCENT}]{idx}. {label}[/]\")\n else:\n parts.append(f\"[{_UI_MUTED}]{idx}. {label}[/]\")\n console.print(\" \" + \" -> \".join(parts))\n console.print()\n\n\n@lru_cache(maxsize=1)\ndef _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]:\n \"\"\"Return chat-capable providers supported by Quick Start.\"\"\"\n from nanobot.providers.registry import PROVIDERS\n\n result: dict[str, _QuickStartProviderInfo] = {}\n for spec in PROVIDERS:\n if spec.name == \"custom\" or spec.is_oauth or spec.is_transcription_only:\n continue\n result[spec.name] = _QuickStartProviderInfo(\n display_name=spec.display_name or spec.name,\n is_local=spec.is_local,\n default_api_base=spec.default_api_base,\n backend=spec.backend,\n is_direct=spec.is_direct,\n )\n return result\n\n\ndef _get_quick_start_provider_choices() -> dict[str, str]:\n \"\"\"Return Quick Start provider display choices.\"\"\"\n choices: dict[str, str] = {}\n for provider_name, info in _get_quick_start_provider_info().items():\n choices.setdefault(info.display_name, provider_name)\n choices[_QUICK_START_CUSTOM_PROVIDER_CHOICE] = \"custom\"\n return choices\n\n\ndef _quick_start_requires_api_key(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:\n \"\"\"Return whether Quick Start should ask for an API key.\"\"\"\n return provider_name == \"custom\" or not (info and info.is_local)\n\n\ndef _quick_start_requires_base_url(provider_name: str, info: _QuickStartProviderInfo | None) -> bool:\n \"\"\"Return whether Quick Start must ask for a provider base URL.\"\"\"\n if provider_name == \"custom\":\n return True\n if provider_name in _QUICK_START_ENDPOINT_CHOICES:\n return False\n if info is None or info.default_api_base:\n return False\n return info.backend == \"azure_openai\" or (\n info.backend == \"openai_compat\" and (info.is_direct or info.is_local)\n )\n\n\ndef _select_quick_start_api_base(\n provider_name: str,\n provider_display: str,\n info: _QuickStartProviderInfo | None,\n) -> tuple[str, bool] | None | object:\n \"\"\"Return the api_base and whether the user explicitly selected or entered it.\"\"\"\n endpoint_choices = _QUICK_START_ENDPOINT_CHOICES.get(provider_name)\n if endpoint_choices:\n choices = {choice.label: choice.api_base for choice in endpoint_choices}\n answer = _select_with_back(\n f\"Which {provider_display} endpoint should Quick Start use?\",\n list(choices) + [\"<- Back\"],\n default=endpoint_choices[0].label,\n )\n if answer is _BACK_PRESSED or answer == \"<- Back\":\n return _BACK_PRESSED\n if answer is None:\n return None\n assert isinstance(answer, str)\n return choices[answer], True\n\n api_base = info.default_api_base if info else \"\"\n if not _quick_start_requires_base_url(provider_name, info):\n return api_base, False\n\n base_answer = _input_text(\n \"Provider base URL\",\n api_base,\n \"str\",\n )\n if base_answer is _BACK_PRESSED:\n return _BACK_PRESSED\n if base_answer is None:\n return None\n api_base = base_answer.strip().rstrip(\"/\")\n if not api_base:\n console.print(\"[yellow]! Provider base URL is required for this provider[/yellow]\")\n return None\n return api_base, True\n\n\ndef _configure_quick_start_provider(config: Config) -> bool | object:\n \"\"\"Configure the beginner path from provider credentials and model.\"\"\"\n while True:\n _show_quick_start_progress(1)\n\n provider_choices = _get_quick_start_provider_choices()\n answer = _select_with_back(\n \"Which provider do you want to use?\",\n list(provider_choices) + [\"<- Back\"],\n )\n if answer is _BACK_PRESSED or answer is None or answer == \"<- Back\":\n return _BACK_PRESSED\n assert isinstance(answer, str)\n provider_name = provider_choices[answer]\n provider_info = _get_quick_start_provider_info().get(provider_name)\n\n api_base = provider_info.default_api_base if provider_info else \"\"\n base_was_prompted = False\n if provider_name in _QUICK_START_ENDPOINT_CHOICES:\n api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info)\n if api_base_result is _BACK_PRESSED:\n continue\n if api_base_result is None:\n return False\n api_base, base_was_prompted = api_base_result\n\n api_key: str | None = None\n if _quick_start_requires_api_key(provider_name, provider_info):\n api_key = _input_text(f\"{answer} API key\", \"\", \"str\")\n if api_key is _BACK_PRESSED:\n continue\n if api_key is None:\n return False\n api_key = api_key.strip()\n if not api_key:\n console.print(\"[yellow]! API key is required for Quick Start[/yellow]\")\n return False\n\n if (\n provider_name not in _QUICK_START_ENDPOINT_CHOICES\n and _quick_start_requires_base_url(provider_name, provider_info)\n ):\n api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info)\n if api_base_result is _BACK_PRESSED:\n continue\n if api_base_result is None:\n return False\n api_base, base_was_prompted = api_base_result\n\n provider_config = getattr(config.providers, provider_name, None)\n if provider_config is None:\n console.print(f\"[red]Unknown provider: {provider_name}[/red]\")\n return False\n\n model = _input_model_with_autocomplete(\"Model ID\", \"\", provider_name)\n if model is _BACK_PRESSED:\n continue\n model = (model or \"\").strip()\n if not model:\n console.print(\"[yellow]! Model ID is required for Quick Start[/yellow]\")\n return False\n\n if api_key is not None:\n provider_config.api_key = api_key\n if api_base:\n if base_was_prompted:\n provider_config.api_base = api_base\n elif not provider_config.api_base:\n provider_config.api_base = api_base\n\n _set_primary_quick_start_preset(\n config,\n provider_name,\n model,\n )\n return True\n\n\ndef _enable_quick_start_websocket_defaults(config: Config) -> bool:\n \"\"\"Enable local WebUI with the default WebSocket settings.\"\"\"\n _show_quick_start_progress(2)\n console.print(\n f\"[{_UI_ACCENT}]Quick Start will enable the WebSocket channel for the local WebUI.[/]\"\n )\n console.print(\n f\"[{_UI_MUTED}]This lets the browser UI at http://127.0.0.1:8765 connect to nanobot.[/]\"\n )\n console.print()\n while True:\n answer = _get_questionary().confirm(\n \"Enable WebSocket channel now?\",\n default=True,\n ).ask()\n if not answer:\n console.print(\n \"[yellow]! Quick Start needs the WebSocket channel for the local WebUI[/yellow]\"\n )\n return False\n webui_secret = _input_secret(\"Set a WebUI password\")\n if webui_secret is _BACK_PRESSED:\n continue\n if not webui_secret:\n console.print(\"[yellow]! WebUI password is required when enabling WebSocket[/yellow]\")\n return False\n break\n\n config_cls = _get_channel_config_class(\"websocket\")\n if config_cls is None:\n console.print(\"[red]No configuration class found for websocket[/red]\")\n return False\n\n current = getattr(config.channels, \"websocket\", None) or {}\n model = config_cls.model_validate(current)\n if hasattr(model, \"enabled\"):\n setattr(model, \"enabled\", True)\n if hasattr(model, \"token_issue_secret\"):\n setattr(model, \"token_issue_secret\", webui_secret)\n if hasattr(model, \"websocket_requires_token\"):\n setattr(model, \"websocket_requires_token\", True)\n setattr(config.channels, \"websocket\", model.model_dump(by_alias=True, exclude_none=True))\n return True\n\n\ndef _show_quick_start_summary(config: Config) -> None:\n \"\"\"Show the small summary users need before returning to the menu.\"\"\"\n _show_quick_start_progress(3)\n preset = config.model_presets.get(\"primary\")\n provider_label = \"AI provider\"\n has_api_key = True\n if preset:\n provider_config = getattr(config.providers, preset.provider, None)\n provider_label, _is_gateway, is_local, _api_base = _get_provider_info().get(\n preset.provider, (preset.provider, False, False, \"\")\n )\n has_api_key = is_local or bool(provider_config and provider_config.api_key)\n\n start_command = \"`nanobot gateway`\"\n next_step = f\"Run {start_command}\"\n status = \"Ready\"\n if not has_api_key:\n status = f\"{provider_label} API key missing\"\n next_step = f\"Add your {provider_label} API key, then run {start_command}\"\n\n rows = [\n (\"Status\", status),\n (\"Next\", next_step),\n (\"WebSocket channel\", \"enabled\"),\n (\"Open\", \"http://127.0.0.1:8765\"),\n ]\n _print_summary_panel(rows, \"Quick Start\")\n\n\ndef _configure_quick_start(config: Config) -> bool:\n \"\"\"First-run path: provider + API key + local WebUI, with advanced settings hidden.\"\"\"\n console.clear()\n _show_section_header(\n \"Quick Start\",\n \"Choose provider endpoint, add credentials and model, then enable the local WebUI channel.\",\n )\n draft = config.model_copy(deep=True)\n provider_result = _configure_quick_start_provider(draft)\n if provider_result is _BACK_PRESSED:\n return False\n if not provider_result:\n _pause()\n return False\n if not _enable_quick_start_websocket_defaults(draft):\n _pause()\n return False\n _show_quick_start_summary(draft)\n _pause(\"Press Enter to save and exit...\")\n for field_name in type(config).model_fields:\n setattr(config, field_name, getattr(draft, field_name))\n return True\n\n\n# --- Main Entry Point ---\n\n\ndef _has_unsaved_changes(original: Config, current: Config) -> bool:\n \"\"\"Return True when the onboarding session has committed changes.\"\"\"\n return original.model_dump(by_alias=True) != current.model_dump(by_alias=True)\n\n\ndef _prompt_main_menu_exit(has_unsaved_changes: bool) -> str:\n \"\"\"Resolve how to leave the main menu.\"\"\"\n if not has_unsaved_changes:\n return \"discard\"\n\n answer = _get_questionary().select(\n \"You have unsaved changes. What would you like to do?\",\n choices=[\n \"[S] Save and Exit\",\n \"[X] Exit Without Saving\",\n \"[R] Resume Editing\",\n ],\n default=\"[R] Resume Editing\",\n qmark=\">\",\n ).ask()\n\n if answer == \"[S] Save and Exit\":\n return \"save\"\n if answer == \"[X] Exit Without Saving\":\n return \"discard\"\n return \"resume\"\n\n\ndef _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]:\n \"\"\"Return the top-level choices, keeping save actions hidden until needed.\"\"\"\n choices = [\n _QUICK_START_MENU_CHOICE,\n \"[A] Advanced Settings\",\n ]\n if has_unsaved_changes:\n choices.extend([\"[S] Save and Exit\", \"[X] Exit Without Saving\"])\n else:\n choices.append(\"[X] Exit\")\n return choices\n\n\ndef _configure_advanced_settings(config: Config) -> None:\n \"\"\"Show lower-frequency setup options behind one advanced menu.\"\"\"\n last_choice: str | None = None\n choices = [\n \"[P] LLM Provider\",\n \"[M] Model Presets\",\n \"[C] Chat Channel\",\n \"[H] Channel Common\",\n \"[A] Agent Settings\",\n \"[I] API Server\",\n \"[G] Gateway\",\n \"[T] Tools\",\n \"[V] View Configuration Summary\",\n \"<- Back\",\n ]\n while True:\n try:\n console.clear()\n _show_section_header(\n \"Advanced Settings\",\n \"Use these when the default API-key setup is not enough.\",\n )\n answer = _select_with_back(\n \"What would you like to configure?\",\n choices,\n default=last_choice,\n )\n except KeyboardInterrupt:\n break\n\n if answer is _BACK_PRESSED or answer is None or answer == \"<- Back\":\n break\n\n _advanced_dispatch = {\n \"[P] LLM Provider\": lambda: _configure_providers(config),\n \"[M] Model Presets\": lambda: _configure_model_presets(config),\n \"[C] Chat Channel\": lambda: _configure_channels(config),\n \"[H] Channel Common\": lambda: _configure_general_settings(config, \"Channel Common\"),\n \"[A] Agent Settings\": lambda: _configure_general_settings(config, \"Agent Settings\"),\n \"[I] API Server\": lambda: _configure_general_settings(config, \"API Server\"),\n \"[G] Gateway\": lambda: _configure_general_settings(config, \"Gateway\"),\n \"[T] Tools\": lambda: _configure_general_settings(config, \"Tools\"),\n \"[V] View Configuration Summary\": lambda: _show_summary(config),\n }\n action_fn = _advanced_dispatch.get(answer)\n if action_fn:\n last_choice = answer\n action_fn()\n\n\ndef run_onboard(initial_config: Config | None = None) -> OnboardResult:\n \"\"\"Run the interactive onboarding questionnaire.\n\n Args:\n initial_config: Optional pre-loaded config to use as starting point.\n If None, loads from config file or creates new default.\n \"\"\"\n _get_questionary()\n\n if initial_config is not None:\n base_config = initial_config.model_copy(deep=True)\n else:\n config_path = get_config_path()\n if config_path.exists():\n base_config = load_config()\n else:\n base_config = Config()\n\n original_config = base_config.model_copy(deep=True)\n config = base_config.model_copy(deep=True)\n _sync_preset_cache(config)\n\n while True:\n console.clear()\n _show_main_menu_header()\n\n try:\n answer = _select_with_back(\n \"What would you like to do?\",\n _get_main_menu_choices(_has_unsaved_changes(original_config, config)),\n )\n except KeyboardInterrupt:\n answer = None\n\n if answer is _BACK_PRESSED or answer is None:\n action = _prompt_main_menu_exit(_has_unsaved_changes(original_config, config))\n if action == \"save\":\n return OnboardResult(config=config, should_save=True)\n if action == \"discard\":\n return OnboardResult(config=original_config, should_save=False)\n continue\n\n if answer == _QUICK_START_MENU_CHOICE:\n if _configure_quick_start(config):\n return OnboardResult(config=config, should_save=True)\n continue\n\n if answer == \"[S] Save and Exit\":\n return OnboardResult(config=config, should_save=True)\n if answer in {\"[X] Exit\", \"[X] Exit Without Saving\"}:\n return OnboardResult(config=original_config, should_save=False)\n if answer == \"[A] Advanced Settings\":\n _configure_advanced_settings(config)\n\n\ndef run_quick_start_onboard(initial_config: Config) -> OnboardResult:\n \"\"\"Run the compact provider + local WebUI setup path directly.\"\"\"\n _get_questionary()\n draft = initial_config.model_copy(deep=True)\n if _configure_quick_start(draft):\n return OnboardResult(config=draft, should_save=True)\n return OnboardResult(config=initial_config, should_save=False)\n"}}, "agent_core/config.py": {"87ab4842c02a1341d9678a703b633523e09c99a5": {"t": 1785674248.9756489, "n": 4403, "h": "1419d1865fff6a57", "c": "\"\"\"Configuration loading for Qwen-backed agents.\n\nThe local key file is intentionally flexible because course projects often keep\nAPI credentials in ad-hoc files. Supported formats:\n- JSON object: {\"DASHSCOPE_API_KEY\": \"...\"} or {\"api_key\": \"...\"}\n- JSON string: \"sk-...\"\n- dotenv line: DASHSCOPE_API_KEY=sk-...\n- raw token: sk-...\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Mapping\n\n\nDEFAULT_CONFIG_PATH = Path(\"api.json\")\nDEFAULT_QWEN_BASE_URL = \"https://dashscope.aliyuncs.com/compatible-mode/v1\"\nDEFAULT_QWEN_MODEL = \"qwen3.5-omni-flash\"\n\n\n@dataclass(frozen=True)\nclass Settings:\n api_key: str\n base_url: str = DEFAULT_QWEN_BASE_URL\n model: str = DEFAULT_QWEN_MODEL\n temperature: float = 0.2\n max_tokens: int = 2200\n timeout_seconds: float = 90.0\n thinking_enabled: bool = False\n reasoning_effort: str = \"none\"\n\n @property\n def chat_completions_url(self) -> str:\n return self.base_url.rstrip(\"/\") + \"/chat/completions\"\n\n\ndef load_settings(\n config_path: str | Path = DEFAULT_CONFIG_PATH,\n env: Mapping[str, str] | None = None,\n model: str | None = None,\n) -> Settings:\n env = env or os.environ\n api_key = _load_api_key(config_path=config_path, env=env)\n\n base_url = env.get(\"DASHSCOPE_BASE_URL\", env.get(\"QWEN_BASE_URL\", DEFAULT_QWEN_BASE_URL))\n resolved_model = model or env.get(\"QWEN_OMNI_MODEL\", env.get(\"DASHSCOPE_MODEL\", DEFAULT_QWEN_MODEL))\n\n return Settings(\n api_key=api_key,\n base_url=base_url,\n model=resolved_model,\n temperature=float(env.get(\"QWEN_TEMPERATURE\", env.get(\"DASHSCOPE_TEMPERATURE\", \"0.2\"))),\n max_tokens=int(env.get(\"QWEN_MAX_TOKENS\", env.get(\"DASHSCOPE_MAX_TOKENS\", \"2200\"))),\n timeout_seconds=float(env.get(\"QWEN_TIMEOUT_SECONDS\", env.get(\"DASHSCOPE_TIMEOUT_SECONDS\", \"90\"))),\n thinking_enabled=_parse_bool(env.get(\"QWEN_THINKING\", \"false\")),\n reasoning_effort=env.get(\"QWEN_REASONING_EFFORT\", \"none\"),\n )\n\n\ndef _load_api_key(config_path: str | Path, env: Mapping[str, str]) -> str:\n for name in (\"DASHSCOPE_API_KEY\", \"QWEN_API_KEY\", \"ALIBABA_API_KEY\", \"API_KEY\"):\n value = env.get(name)\n if value:\n return _clean_secret(value)\n\n path = Path(config_path)\n if not path.exists():\n raise RuntimeError(\n f\"Qwen API key not found. Set DASHSCOPE_API_KEY or create {path}.\"\n )\n\n text = path.read_text(encoding=\"utf-8\").strip()\n if not text:\n raise RuntimeError(f\"{path} is empty.\")\n\n parsed = _try_json(text)\n if isinstance(parsed, str):\n return _clean_secret(parsed)\n if isinstance(parsed, dict):\n for name in (\n \"DASHSCOPE_API_KEY\",\n \"dashscope_api_key\",\n \"QWEN_API_KEY\",\n \"qwen_api_key\",\n \"ALIBABA_API_KEY\",\n \"api_key\",\n \"key\",\n ):\n value = parsed.get(name)\n if isinstance(value, str) and value.strip():\n return _clean_secret(value)\n\n dotenv_value = _parse_dotenv_secret(text)\n if dotenv_value:\n return _clean_secret(dotenv_value)\n\n # Last-resort compatibility for files that contain only the token.\n if \"\\n\" not in text and \"=\" not in text and \" \" not in text:\n return _clean_secret(text)\n\n raise RuntimeError(\n f\"Could not find a Qwen API key in {path}. Supported keys: \"\n \"DASHSCOPE_API_KEY, dashscope_api_key, QWEN_API_KEY, qwen_api_key, api_key, key.\"\n )\n\n\ndef _try_json(text: str) -> object | None:\n try:\n return json.loads(text)\n except json.JSONDecodeError:\n return None\n\n\ndef _parse_dotenv_secret(text: str) -> str | None:\n wanted = {\n \"DASHSCOPE_API_KEY\",\n \"QWEN_API_KEY\",\n \"ALIBABA_API_KEY\",\n \"API_KEY\",\n \"api_key\",\n \"key\",\n }\n for raw_line in text.splitlines():\n line = raw_line.strip()\n if not line or line.startswith(\"#\") or \"=\" not in line:\n continue\n name, value = line.split(\"=\", 1)\n if name.strip() in wanted and value.strip():\n return value.strip()\n return None\n\n\ndef _clean_secret(value: str) -> str:\n return value.strip().strip(\"'\\\"\")\n\n\ndef _parse_bool(value: str) -> bool:\n return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\", \"enabled\"}\n"}}, "nanobot/webui/src/tests/settings-view.test.tsx": {"87ab4842c02a1341d9678a703b633523e09c99a5": {"t": 1785746560.2267282, "n": 44154, "h": "6a852d8e587f8202", "c": "import { fireEvent, render, screen, waitFor } from \"@testing-library/react\";\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\n\nimport { SettingsView } from \"@/components/settings/SettingsView\";\nimport { ClientProvider } from \"@/providers/ClientProvider\";\nimport type { SettingsPayload } from \"@/lib/types\";\n\nfunction jsonResponse(body: unknown): Response {\n return {\n ok: true,\n status: 200,\n json: async () => body,\n } as Response;\n}\n\nfunction settingsPayload(): SettingsPayload {\n return {\n agent: {\n model: \"openai/gpt-4o\",\n provider: \"auto\",\n resolved_provider: \"openai\",\n has_api_key: true,\n model_preset: \"default\",\n max_tokens: 8192,\n context_window_tokens: 200000,\n temperature: 0.1,\n reasoning_effort: null,\n timezone: \"UTC\",\n bot_name: \"nanobot\",\n bot_icon: \"nb\",\n tool_hint_max_length: 40,\n },\n model_presets: [{\n name: \"default\",\n label: \"Default\",\n active: true,\n is_default: true,\n model: \"openai/gpt-4o\",\n provider: \"auto\",\n max_tokens: 8192,\n context_window_tokens: 200000,\n temperature: 0.1,\n reasoning_effort: null,\n }],\n providers: [],\n web_search: {\n provider: \"duckduckgo\",\n api_key_hint: null,\n base_url: null,\n max_results: 5,\n timeout: 30,\n providers: [{ name: \"duckduckgo\", label: \"DuckDuckGo\", credential: \"none\" }],\n },\n web: {\n enable: true,\n proxy: null,\n user_agent: null,\n search: { max_results: 5, timeout: 30 },\n fetch: { use_jina_reader: true },\n },\n image_generation: {\n enabled: false,\n provider: \"openrouter\",\n provider_configured: false,\n model: \"openai/gpt-5.4-image-2\",\n default_aspect_ratio: \"1:1\",\n default_image_size: \"1K\",\n max_images_per_turn: 4,\n save_dir: \"generated\",\n providers: [],\n },\n runtime: {\n config_path: \"/tmp/config.json\",\n workspace_path: \"/tmp/workspace\",\n gateway_host: \"127.0.0.1\",\n gateway_port: 18790,\n heartbeat: {\n enabled: true,\n interval_s: 1800,\n keep_recent_messages: 8,\n },\n dream: {\n schedule: \"every 2h\",\n },\n unified_session: false,\n },\n advanced: {\n restrict_to_workspace: false,\n webui_allow_local_service_access: true,\n webui_default_access_mode: \"default\",\n private_service_protection_enabled: true,\n ssrf_whitelist_count: 0,\n mcp_server_count: 0,\n exec_enabled: true,\n exec_sandbox: null,\n exec_path_prepend_set: false,\n exec_path_append_set: false,\n },\n requires_restart: false,\n };\n}\n\nfunction autoDynamicProviderPayload(\n options: {\n configured: boolean;\n hasApiKey: boolean;\n apiBase: string | null;\n apiKeyHint: string | null;\n },\n): SettingsPayload {\n const base = settingsPayload();\n return {\n ...base,\n agent: {\n ...base.agent,\n model: \"companyProxy/gpt-4o\",\n provider: \"companyProxy\",\n resolved_provider: \"companyProxy\",\n has_api_key: options.hasApiKey,\n },\n model_presets: [\n {\n ...base.model_presets[0],\n model: \"companyProxy/gpt-4o\",\n provider: \"auto\",\n },\n ],\n providers: [\n {\n name: \"companyProxy\",\n label: \"Company Proxy\",\n configured: options.configured,\n auth_type: \"api_key\",\n api_key_required: false,\n api_key_hint: options.apiKeyHint,\n api_base: options.apiBase,\n default_api_base: null,\n },\n ],\n };\n}\n\nconst installedAnyGen = {\n name: \"anygen\",\n display_name: \"AnyGen\",\n category: \"generation\",\n description: \"Generate docs, slides, websites and more via AnyGen cloud API\",\n requires: \"ANYGEN_API_KEY\",\n source: \"harness\",\n entry_point: \"cli-anything-anygen\",\n install_supported: true,\n installed: true,\n available: true,\n status: \"installed\",\n logo_url: \"https://www.google.com/s2/favicons?domain=anygen.io&sz=64\",\n brand_color: \"#111827\",\n skill_installed: true,\n};\n\nfunction renderSettingsView(\n options: {\n initialSection?: \"overview\" | \"appearance\" | \"apps\" | \"automations\" | \"advanced\" | \"models\" | \"browser\";\n initialSettings?: SettingsPayload;\n showSidebar?: boolean;\n onSettingsChange?: (payload: SettingsPayload) => void;\n onNativeEngineRestart?: () => Promise;\n } = {},\n) {\n render(\n \n {}}\n onBackToChat={() => {}}\n onModelNameChange={() => {}}\n onSettingsChange={options.onSettingsChange}\n onNativeEngineRestart={options.onNativeEngineRestart}\n />\n ,\n );\n}\n\ndescribe(\"SettingsView Apps catalog\", () => {\n afterEach(() => {\n localStorage.removeItem(\"nanobot-webui.settings-preferences\");\n vi.useRealTimers();\n vi.unstubAllGlobals();\n });\n\n it(\"persists the file edit display local preference\", async () => {\n renderSettingsView({\n initialSection: \"appearance\",\n initialSettings: settingsPayload(),\n showSidebar: true,\n });\n\n expect(screen.getByText(\"File edit display\")).toBeInTheDocument();\n fireEvent.click(screen.getByRole(\"button\", { name: \"Diff\" }));\n\n await waitFor(() => {\n const saved = JSON.parse(localStorage.getItem(\"nanobot-webui.settings-preferences\") || \"{}\");\n expect(saved.fileEditDisplayMode).toBe(\"diff\");\n });\n });\n\n it(\"does not show the Settings kicker on the standalone Automations surface\", async () => {\n vi.stubGlobal(\"fetch\", vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/webui/automations\") return jsonResponse({ jobs: [] });\n return jsonResponse({});\n }));\n\n renderSettingsView({\n initialSection: \"automations\",\n initialSettings: settingsPayload(),\n showSidebar: false,\n });\n\n expect(screen.getByRole(\"heading\", { name: \"Automations\" })).toBeInTheDocument();\n expect(await screen.findByText(\"No automations yet.\")).toBeInTheDocument();\n expect(screen.queryByText(\"Settings\")).not.toBeInTheDocument();\n });\n\n it(\"shows a visible uninstall button for installed CLI apps and calls uninstall\", async () => {\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") {\n return jsonResponse(settingsPayload());\n }\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({\n apps: [installedAnyGen],\n installed_count: 1,\n catalog_updated_at: \"2026-04-18\",\n });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n if (url === \"/api/settings/cli-apps/uninstall?name=anygen\") {\n return jsonResponse({\n apps: [{ ...installedAnyGen, installed: false, status: \"available\" }],\n installed_count: 0,\n catalog_updated_at: \"2026-04-18\",\n last_action: {\n ok: true,\n message: \"Uninstalled CLI for AnyGen.\",\n still_available: false,\n },\n });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView();\n\n expect(await screen.findByRole(\"heading\", { name: \"Apps\" })).toBeInTheDocument();\n expect(await screen.findByText(\"AnyGen\")).toBeInTheDocument();\n const uninstall = screen.getByRole(\"button\", { name: \"Uninstall CLI\" });\n\n fireEvent.click(uninstall);\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/cli-apps/uninstall?name=anygen\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n expect(await screen.findByText(\"Uninstalled CLI for AnyGen.\")).toBeInTheDocument();\n\n fireEvent.click(screen.getByRole(\"button\", { name: \"Dismiss\" }));\n\n expect(screen.queryByText(\"Uninstalled CLI for AnyGen.\")).not.toBeInTheDocument();\n });\n\n it(\"shows nanobot optional features and enables one\", async () => {\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n if (url === \"/api/settings/nanobot-features\") {\n return jsonResponse({\n features: [{\n name: \"matrix\",\n display_name: \"Matrix\",\n type: \"channel\",\n enabled: false,\n installed: false,\n ready: false,\n status: \"missing_dependency\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 0,\n });\n }\n if (url === \"/api/settings/nanobot-features/enable?name=matrix\") {\n return jsonResponse({\n features: [{\n name: \"matrix\",\n display_name: \"Matrix\",\n type: \"channel\",\n enabled: true,\n installed: true,\n ready: true,\n status: \"enabled\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 1,\n last_action: { ok: true, message: \"Enabled channel 'matrix'\", enabled: true },\n });\n }\n if (url === \"/api/settings/nanobot-features/disable?name=matrix\") {\n return jsonResponse({\n features: [{\n name: \"matrix\",\n display_name: \"Matrix\",\n type: \"channel\",\n enabled: false,\n installed: true,\n ready: false,\n status: \"not_enabled\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 0,\n requires_restart: true,\n last_action: { ok: true, message: \"Disabled channel 'matrix'\", enabled: false },\n });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView();\n\n expect(await screen.findByText(\"Matrix\")).toBeInTheDocument();\n expect(screen.queryByText(/Enabling Nanobot features may install Python packages/)).not.toBeInTheDocument();\n fireEvent.click(screen.getByRole(\"button\", { name: \"Install support\" }));\n expect(screen.getByRole(\"dialog\", { name: \"Install support for Matrix?\" })).toBeInTheDocument();\n expect(screen.getByText(\"nanobot will add what Matrix needs, then turn it on. Continue?\")).toBeInTheDocument();\n expect(fetchMock).not.toHaveBeenCalledWith(\n \"/api/settings/nanobot-features/enable?name=matrix\",\n expect.anything(),\n );\n fireEvent.click(screen.getByRole(\"button\", { name: \"Install and enable\" }));\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/nanobot-features/enable?name=matrix\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n expect(await screen.findByText(\"Enabled channel 'matrix'\")).toBeInTheDocument();\n expect(screen.getByText(\"Restart nanobot to apply updated apps and features.\")).toBeInTheDocument();\n\n fireEvent.click(screen.getByRole(\"button\", { name: \"Disable\" }));\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/nanobot-features/disable?name=matrix\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n expect(await screen.findByText(\"Disabled channel 'matrix'\")).toBeInTheDocument();\n });\n\n it(\"shows enabled nanobot channels with missing support as enabled\", async () => {\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n if (url === \"/api/settings/nanobot-features\") {\n return jsonResponse({\n features: [{\n name: \"matrix\",\n display_name: \"Matrix\",\n type: \"channel\",\n enabled: true,\n installed: false,\n ready: false,\n status: \"missing_dependency\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 1,\n });\n }\n if (url === \"/api/settings/nanobot-features/enable?name=matrix\") {\n return jsonResponse({\n features: [{\n name: \"matrix\",\n display_name: \"Matrix\",\n type: \"channel\",\n enabled: true,\n installed: true,\n ready: true,\n status: \"enabled\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 1,\n last_action: { ok: true, message: \"Enabled channel 'matrix'\", enabled: true },\n });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView();\n\n expect(await screen.findByText(\"Matrix\")).toBeInTheDocument();\n expect(screen.getByText(\"1 Plugin · 0 CLI · 0 MCP\")).toBeInTheDocument();\n expect(screen.getByText(\"Support missing\")).toBeInTheDocument();\n\n fireEvent.click(screen.getByRole(\"button\", { name: \"Install support\" }));\n fireEvent.click(screen.getByRole(\"button\", { name: \"Install and enable\" }));\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/nanobot-features/enable?name=matrix\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n });\n\n it(\"does not offer to disable the websocket channel\", async () => {\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n if (url === \"/api/settings/nanobot-features\") {\n return jsonResponse({\n features: [{\n name: \"websocket\",\n display_name: \"Websocket\",\n type: \"channel\",\n enabled: true,\n installed: true,\n ready: true,\n status: \"enabled\",\n install_supported: true,\n requires_restart: true,\n }],\n enabled_count: 1,\n });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView();\n\n expect(await screen.findByText(\"Websocket\")).toBeInTheDocument();\n expect(screen.getByText(\"Required for WebUI\")).toBeInTheDocument();\n expect(screen.queryByRole(\"button\", { name: \"Disable\" })).not.toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"Required for WebUI\" })).toBeDisabled();\n expect(fetchMock).not.toHaveBeenCalledWith(\n \"/api/settings/nanobot-features/disable?name=websocket\",\n expect.anything(),\n );\n });\n\n it(\"publishes the latest settings payload to the shell\", async () => {\n const payload = settingsPayload();\n const onSettingsChange = vi.fn();\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ onSettingsChange });\n\n await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));\n });\n\n it(\"does not keep Apps loading while an empty CLI catalog refresh is pending\", async () => {\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({\n apps: [],\n installed_count: 0,\n catalog_updated_at: null,\n catalog_refresh_pending: true,\n });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView();\n\n expect(await screen.findByText(\"No apps match this filter.\")).toBeInTheDocument();\n expect(screen.queryByText(\"Loading Apps...\")).not.toBeInTheDocument();\n });\n\n it(\"shows token activity on the overview\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n usage: {\n days: [\n {\n date: \"2026-06-03\",\n prompt_tokens: 1200,\n completion_tokens: 300,\n cached_tokens: 500,\n total_tokens: 1500,\n requests: 2,\n },\n ],\n total_tokens: 1500,\n total_tokens_30d: 1500,\n total_tokens_365d: 1500,\n peak_day_tokens: 1500,\n current_streak_days: 1,\n longest_streak_days: 1,\n active_days_30d: 1,\n requests_30d: 2,\n updated_at: \"2026-06-03T00:00:00Z\",\n },\n };\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"overview\" });\n\n expect(await screen.findByLabelText(\"Token activity\")).toBeInTheDocument();\n expect(screen.getByText(\"Token Usage\")).toBeInTheDocument();\n expect(screen.queryByText(\"Token activity\")).not.toBeInTheDocument();\n expect(screen.queryByText(\"Total tokens\")).not.toBeInTheDocument();\n expect(screen.queryByText(\"Peak tokens\")).not.toBeInTheDocument();\n });\n\n it(\"aligns token activity days with the configured timezone\", async () => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date(\"2026-06-02T18:00:00Z\"));\n const basePayload = settingsPayload();\n const payload: SettingsPayload = {\n ...basePayload,\n agent: {\n ...basePayload.agent,\n timezone: \"Asia/Shanghai\",\n },\n usage: {\n days: [\n {\n date: \"2026-06-03\",\n prompt_tokens: 1200,\n completion_tokens: 300,\n cached_tokens: 500,\n total_tokens: 1500,\n requests: 2,\n },\n ],\n total_tokens: 1500,\n total_tokens_30d: 1500,\n total_tokens_365d: 1500,\n peak_day_tokens: 1500,\n current_streak_days: 1,\n longest_streak_days: 1,\n active_days_30d: 1,\n requests_30d: 2,\n updated_at: \"2026-06-03T00:00:00Z\",\n },\n };\n vi.stubGlobal(\"fetch\", vi.fn(() => new Promise(() => {})));\n\n renderSettingsView({ initialSection: \"overview\", initialSettings: payload });\n\n expect(screen.getByLabelText(\"2026-06-03: 1.5K tokens, 2 requests\")).toBeInTheDocument();\n });\n\n it(\"shows context window options in model settings\", async () => {\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"models\" });\n\n expect(await screen.findByText(\"Context window\")).toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"64K\" })).toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"200K\" })).toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"256K\" })).toBeInTheDocument();\n });\n\n it(\"uses the resolved provider row for auto dynamic providers without api keys\", async () => {\n vi.stubGlobal(\"fetch\", vi.fn(() => new Promise(() => {})));\n\n renderSettingsView({\n initialSection: \"models\",\n initialSettings: autoDynamicProviderPayload({\n configured: true,\n hasApiKey: false,\n apiBase: \"https://proxy.example.test/v1\",\n apiKeyHint: null,\n }),\n });\n\n const configurationButton = await screen.findByRole(\"button\", {\n name: \"Current configuration\",\n });\n expect(configurationButton).toHaveTextContent(\"companyProxy/gpt-4o\");\n expect(configurationButton).toHaveTextContent(\"Company Proxy\");\n expect(configurationButton).not.toHaveTextContent(\"Not configured\");\n });\n\n it(\"does not treat auto dynamic provider api keys as configured without apiBase\", async () => {\n vi.stubGlobal(\"fetch\", vi.fn(() => new Promise(() => {})));\n\n renderSettingsView({\n initialSection: \"models\",\n initialSettings: autoDynamicProviderPayload({\n configured: false,\n hasApiKey: true,\n apiBase: null,\n apiKeyHint: \"sk-...\",\n }),\n });\n\n const configurationButton = await screen.findByRole(\"button\", {\n name: \"Current configuration\",\n });\n expect(configurationButton).toHaveTextContent(\"Not configured\");\n expect(configurationButton).toHaveTextContent(\"Company Proxy · companyProxy/gpt-4o\");\n });\n\n it(\"marks the current model as unconfigured when its provider needs setup\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n agent: {\n ...settingsPayload().agent,\n model: \"openai-codex/gpt-5.1-codex\",\n provider: \"openai_codex\",\n resolved_provider: \"openai_codex\",\n has_api_key: false,\n },\n model_presets: [\n {\n ...settingsPayload().model_presets[0],\n model: \"openai-codex/gpt-5.1-codex\",\n provider: \"openai_codex\",\n },\n ],\n providers: [\n {\n name: \"openai_codex\",\n label: \"OpenAI Codex\",\n configured: false,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: null,\n oauth_account: null,\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n ],\n };\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"models\" });\n\n const configurationButton = await screen.findByRole(\"button\", {\n name: \"Current configuration\",\n });\n expect(configurationButton).toHaveTextContent(\"Not configured\");\n expect(configurationButton).toHaveTextContent(\"OpenAI Codex · openai-codex/gpt-5.1-codex\");\n expect(await screen.findByRole(\"button\", { name: \"Sign in\" })).toBeInTheDocument();\n });\n\n it(\"keeps unsigned OAuth providers out of the active provider picker\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n agent: {\n ...settingsPayload().agent,\n model: \"deepseek-chat\",\n provider: \"deepseek\",\n resolved_provider: \"deepseek\",\n },\n model_presets: [\n {\n ...settingsPayload().model_presets[0],\n model: \"deepseek-chat\",\n provider: \"deepseek\",\n },\n ],\n providers: [\n {\n name: \"deepseek\",\n label: \"DeepSeek\",\n configured: true,\n auth_type: \"api_key\",\n api_key_required: true,\n api_key_hint: \"sk-...\",\n api_base: \"https://api.deepseek.com\",\n default_api_base: \"https://api.deepseek.com\",\n },\n {\n name: \"openai_codex\",\n label: \"OpenAI Codex\",\n configured: false,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: null,\n oauth_account: null,\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n {\n name: \"github_copilot\",\n label: \"GitHub Copilot\",\n configured: false,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: \"https://api.githubcopilot.com\",\n oauth_account: null,\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n ],\n };\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"models\" });\n\n const deepseekButtons = await screen.findAllByRole(\"button\", { name: /DeepSeek/ });\n const providerPicker = deepseekButtons.find(\n (button) => button.getAttribute(\"aria-haspopup\") === \"menu\",\n );\n if (!providerPicker) throw new Error(\"provider picker was not found\");\n fireEvent.pointerDown(providerPicker);\n\n expect(await screen.findByRole(\"menuitem\", { name: /DeepSeek/ })).toBeInTheDocument();\n expect(screen.queryByRole(\"menuitem\", { name: /OpenAI Codex/ })).not.toBeInTheDocument();\n expect(screen.queryByRole(\"menuitem\", { name: /GitHub Copilot/ })).not.toBeInTheDocument();\n });\n\n it(\"does not fetch model lists for unsigned OAuth providers\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n agent: {\n ...settingsPayload().agent,\n model: \"\",\n provider: \"openai_codex\",\n resolved_provider: \"openai_codex\",\n },\n model_presets: [\n {\n ...settingsPayload().model_presets[0],\n model: \"\",\n provider: \"openai_codex\",\n },\n ],\n providers: [\n {\n name: \"openai_codex\",\n label: \"OpenAI Codex\",\n configured: false,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: null,\n oauth_account: null,\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n {\n name: \"github_copilot\",\n label: \"GitHub Copilot\",\n configured: false,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: \"https://api.githubcopilot.com\",\n oauth_account: null,\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n ],\n };\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({ initialSection: \"models\" });\n\n fireEvent.pointerDown(await screen.findByRole(\"button\", { name: /Select model/i }));\n expect(\n await screen.findByText(\"Configure this provider before loading models.\"),\n ).toBeInTheDocument();\n expect(\n fetchMock.mock.calls.some(([input]) =>\n String(input).startsWith(\"/api/settings/provider-models\"),\n ),\n ).toBe(false);\n });\n\n it(\"prefills manual model ids for configured OAuth providers\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n agent: {\n ...settingsPayload().agent,\n model: \"open-codex/gpt-5.5\",\n provider: \"openai_codex\",\n resolved_provider: \"openai_codex\",\n },\n model_presets: [\n {\n ...settingsPayload().model_presets[0],\n model: \"open-codex/gpt-5.5\",\n provider: \"openai_codex\",\n },\n ],\n providers: [\n {\n name: \"openai_codex\",\n label: \"OpenAI Codex\",\n configured: true,\n auth_type: \"oauth\",\n api_key_required: false,\n api_key_hint: null,\n api_base: null,\n default_api_base: null,\n oauth_account: \"acct-test\",\n oauth_expires_at: null,\n oauth_login_supported: true,\n },\n ],\n };\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({ initialSection: \"models\" });\n\n const modelButtons = await screen.findAllByRole(\"button\", { name: /open-codex\\/gpt-5\\.5/i });\n fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);\n const input = (await screen.findByPlaceholderText(\"Search or type model ID\")) as HTMLInputElement;\n expect(input.value).toBe(\"open-codex/gpt-5.5\");\n\n fireEvent.change(input, { target: { value: \"openai-codex/gpt-5.5\" } });\n expect(await screen.findByText(\"“openai-codex/gpt-5.5”\")).toBeInTheDocument();\n expect(\n fetchMock.mock.calls.some(([input]) =>\n String(input).startsWith(\"/api/settings/provider-models\"),\n ),\n ).toBe(false);\n });\n\n it(\"can close the new configuration dialog without trapping the settings page\", async () => {\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(settingsPayload());\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"models\" });\n\n const configurationButton = await screen.findByRole(\"button\", { name: \"Current configuration\" });\n fireEvent.pointerDown(configurationButton!);\n fireEvent.click(await screen.findByText(\"Add configuration\"));\n\n expect(await screen.findByRole(\"heading\", { name: \"New model configuration\" })).toBeInTheDocument();\n fireEvent.click(screen.getByRole(\"button\", { name: \"Cancel\" }));\n\n await waitFor(() =>\n expect(screen.queryByRole(\"heading\", { name: \"New model configuration\" })).not.toBeInTheDocument(),\n );\n expect(document.body.style.pointerEvents).not.toBe(\"none\");\n\n fireEvent.pointerDown(configurationButton!);\n expect(await screen.findByText(\"Add configuration\")).toBeInTheDocument();\n });\n\n it(\"loads provider models and lets users choose one without typing the id manually\", async () => {\n const payload: SettingsPayload = {\n ...settingsPayload(),\n agent: {\n ...settingsPayload().agent,\n model: \"deepseek-chat\",\n provider: \"deepseek\",\n resolved_provider: \"deepseek\",\n },\n model_presets: [\n {\n ...settingsPayload().model_presets[0],\n model: \"deepseek-chat\",\n provider: \"deepseek\",\n },\n ],\n providers: [\n {\n name: \"deepseek\",\n label: \"DeepSeek\",\n configured: true,\n auth_type: \"api_key\",\n api_key_required: true,\n api_key_hint: \"sk-...\",\n api_base: \"https://api.deepseek.com\",\n default_api_base: \"https://api.deepseek.com\",\n },\n ],\n };\n const updatedPayload: SettingsPayload = {\n ...payload,\n agent: {\n ...payload.agent,\n model: \"deepseek-reasoner\",\n },\n model_presets: [\n {\n ...payload.model_presets[0],\n model: \"deepseek-reasoner\",\n },\n ],\n };\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n if (url === \"/api/settings/provider-models?provider=deepseek\") {\n return jsonResponse({\n provider: \"deepseek\",\n label: \"DeepSeek\",\n status: \"available\",\n catalog_kind: \"official\",\n models: [\n { id: \"deepseek-chat\", owned_by: \"deepseek\", context_window: 65536 },\n { id: \"deepseek-reasoner\", owned_by: \"deepseek\", context_window: 65536 },\n ],\n model_count: 2,\n fetched_at: 1,\n });\n }\n if (url === \"/api/settings/update?model_preset=default&model=deepseek-reasoner\") {\n return jsonResponse(updatedPayload);\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({ initialSection: \"models\" });\n\n const modelButtons = await screen.findAllByRole(\"button\", { name: /deepseek-chat/i });\n fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);\n await screen.findByText(\"deepseek-reasoner\");\n fireEvent.click(screen.getAllByText(\"deepseek-reasoner\")[0]);\n fireEvent.click(screen.getByRole(\"button\", { name: \"Save\" }));\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/provider-models?provider=deepseek\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/update?model_preset=default&model=deepseek-reasoner\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n });\n\n it(\"saves network safety without exposing technical SSRF copy\", async () => {\n const payload = settingsPayload();\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") {\n return jsonResponse({ apps: [], installed_count: 0 });\n }\n if (url === \"/api/settings/mcp-presets\") {\n return jsonResponse({ presets: [], installed_count: 0 });\n }\n if (url === \"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default\") {\n return jsonResponse({\n ...payload,\n advanced: { ...payload.advanced, webui_allow_local_service_access: false },\n requires_restart: true,\n restart_required_sections: [\"runtime\"],\n });\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({ initialSection: \"advanced\" });\n\n expect(await screen.findByText(\"Web safety\")).toBeInTheDocument();\n expect(screen.queryByText(/SSRF/i)).not.toBeInTheDocument();\n expect(screen.queryByText(\"Private Service Protection\")).not.toBeInTheDocument();\n expect(screen.getByText(\"Default access\")).toBeInTheDocument();\n expect(screen.queryByRole(\"button\", { name: \"Restricted\" })).not.toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"Default Permission\" })).toBeInTheDocument();\n expect(screen.getByRole(\"button\", { name: \"Full Access\" })).toBeInTheDocument();\n\n fireEvent.click(screen.getByRole(\"switch\", { name: \"Local services\" }));\n fireEvent.click(screen.getByRole(\"button\", { name: \"Save\" }));\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n });\n\n it(\"saves optional-key web search providers without an API key\", async () => {\n const payload = {\n ...settingsPayload(),\n web_search: {\n ...settingsPayload().web_search,\n provider: \"duckduckgo\",\n providers: [\n { name: \"duckduckgo\", label: \"DuckDuckGo\", credential: \"none\" as const },\n { name: \"keenable\", label: \"Keenable\", credential: \"optional_api_key\" as const },\n ],\n },\n };\n const updatedPayload = {\n ...payload,\n web_search: {\n ...payload.web_search,\n provider: \"keenable\",\n },\n };\n const fetchMock = vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n if (\n url ===\n \"/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true\"\n ) {\n return jsonResponse(updatedPayload);\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({ initialSection: \"browser\" });\n\n fireEvent.pointerDown(await screen.findByRole(\"button\", { name: /DuckDuckGo/ }));\n fireEvent.click(await screen.findByRole(\"menuitem\", { name: \"Keenable\" }));\n const saveButton = screen\n .getAllByRole(\"button\", { name: \"Save\" })\n .find((button) => !(button as HTMLButtonElement).disabled);\n if (!saveButton) throw new Error(\"enabled Save button was not found\");\n fireEvent.click(saveButton);\n\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer tok\" },\n }),\n ),\n );\n });\n\n it(\"uses native host safety copy on the native surface\", async () => {\n const payload = {\n ...settingsPayload(),\n surface: \"native\" as const,\n runtime_surface: \"native\" as const,\n };\n vi.stubGlobal(\n \"fetch\",\n vi.fn(async (input: RequestInfo | URL) => {\n const url = String(input);\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n }),\n );\n\n renderSettingsView({ initialSection: \"advanced\" });\n\n expect(await screen.findByText(\"App safety\")).toBeInTheDocument();\n expect(screen.queryByText(\"Web safety\")).not.toBeInTheDocument();\n expect(screen.getByText(\"Allow Full Access shell commands to reach services on this Mac.\")).toBeInTheDocument();\n });\n\n it(\"refreshes settings with a fresh token after native engine restart\", async () => {\n const payload = {\n ...settingsPayload(),\n surface: \"native\" as const,\n runtime_surface: \"native\" as const,\n runtime_capabilities: {\n can_restart_engine: true,\n can_pick_folder: true,\n can_open_logs: true,\n can_export_diagnostics: true,\n },\n };\n const restartedPayload = {\n ...payload,\n advanced: { ...payload.advanced, webui_allow_local_service_access: false },\n requires_restart: true,\n restart_required_sections: [\"runtime\"],\n };\n const refreshedPayload = {\n ...restartedPayload,\n requires_restart: false,\n restart_required_sections: [],\n };\n const restartEngine = vi.fn(async () => \"fresh-token\");\n const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {\n const url = String(input);\n const auth = (init?.headers as Record | undefined)?.Authorization;\n if (url === \"/api/settings\" && auth === \"Bearer fresh-token\") {\n return jsonResponse(refreshedPayload);\n }\n if (url === \"/api/settings\") return jsonResponse(payload);\n if (url === \"/api/settings/cli-apps\") return jsonResponse({ apps: [], installed_count: 0 });\n if (url === \"/api/settings/mcp-presets\") return jsonResponse({ presets: [], installed_count: 0 });\n if (url === \"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default\") {\n return jsonResponse(restartedPayload);\n }\n return { ok: false, status: 404, json: async () => ({}) } as Response;\n });\n vi.stubGlobal(\"fetch\", fetchMock);\n\n renderSettingsView({\n initialSection: \"advanced\",\n onNativeEngineRestart: restartEngine,\n });\n\n expect(await screen.findByText(\"App safety\")).toBeInTheDocument();\n fireEvent.click(screen.getByRole(\"switch\", { name: \"Local services\" }));\n fireEvent.click(screen.getByRole(\"button\", { name: \"Save\" }));\n\n await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));\n await waitFor(() =>\n expect(fetchMock).toHaveBeenCalledWith(\n \"/api/settings\",\n expect.objectContaining({\n headers: { Authorization: \"Bearer fresh-token\" },\n }),\n ),\n );\n });\n});\n"}}}}