From 52e0a6a1e31d7b7dabcca9cb54a5e487112e2fef Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:23:27 +0800 Subject: [PATCH] refactor(webui): split settings backend by domain (#5323) --- nanobot/webui/settings_api.py | 2160 ++------------------- nanobot/webui/settings_capabilities.py | 804 ++++++++ nanobot/webui/settings_contracts.py | 82 + nanobot/webui/settings_models.py | 1693 ++++++++++++++++ nanobot/webui/settings_routes.py | 1300 +++---------- nanobot/webui/settings_system.py | 957 +++++++++ tests/webui/test_settings_capabilities.py | 62 + tests/webui/test_settings_models.py | 58 + tests/webui/test_settings_system.py | 44 + 9 files changed, 4117 insertions(+), 3043 deletions(-) create mode 100644 nanobot/webui/settings_capabilities.py create mode 100644 nanobot/webui/settings_contracts.py create mode 100644 nanobot/webui/settings_models.py create mode 100644 nanobot/webui/settings_system.py create mode 100644 tests/webui/test_settings_capabilities.py create mode 100644 tests/webui/test_settings_models.py create mode 100644 tests/webui/test_settings_system.py diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index eb0b4eff0..18438d5c6 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -1,87 +1,43 @@ -"""Settings REST helpers for the WebUI HTTP surface. +"""Stable compatibility facade for WebUI settings. -The WebSocket channel owns transport/authentication. This module owns the -settings payload shape and the allowlisted config mutations exposed to WebUI. +The gateway owns serialization and the explicit config path. Business DTOs, +validation, and updates live in the model/provider, capability, and system +domains; this module preserves the established Python and HTTP-facing seams. """ -# oauth-cli-kit does not publish type stubs. -# pyright: reportMissingTypeStubs=false - from __future__ import annotations -import json -import math -import os -import re -import secrets -import time from collections.abc import Iterable -from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast -from zoneinfo import ZoneInfo import httpx from nanobot import __version__ -from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS -from nanobot.audio.transcription import resolve_transcription_config -from nanobot.audio.transcription_registry import ( - resolve_transcription_provider, - transcription_provider_names, -) -from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config -from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig -from nanobot.providers.image_generation import ( - get_image_gen_provider, - image_gen_provider_names, -) -from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE -from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name -from nanobot.security.network import is_loopback_host -from nanobot.security.workspace_access import workspace_sandbox_status -from nanobot.webui.token_usage import token_usage_payload -from nanobot.webui.workspaces import ( - read_webui_default_access_mode, - write_webui_default_access_mode, -) - -QueryParams = dict[str, list[str]] -RuntimeSurface = Literal["browser", "native"] +from nanobot.config.loader import get_config_path, load_config, save_config +from nanobot.config.schema import Config +from nanobot.webui import settings_capabilities as capabilities +from nanobot.webui import settings_contracts as contracts +from nanobot.webui import settings_models as models +from nanobot.webui import settings_system as system +from nanobot.webui.settings_contracts import QueryParams, WebUISettingsError +from nanobot.webui.workspaces import write_webui_default_access_mode if TYPE_CHECKING: from nanobot.webui.settings_services import WebUIOAuthFlowRegistry +RuntimeSurface = Literal["browser", "native"] -def _version_payload() -> dict[str, Any]: - """Return version info for the settings payload.""" - return { - "current": __version__, - } - - -_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$") -_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest" - - -def _docs_version(version: str) -> str: - """Map package versions to the matching public docs path.""" - normalized = version.strip() - if _DOCS_STABLE_VERSION_RE.fullmatch(normalized): - return normalized - return "latest" - - -def _docs_payload() -> dict[str, Any]: - """Return version-aware documentation links for the WebUI.""" - docs_version = _docs_version(__version__) - base_url = f"https://nanobot.wiki/docs/{docs_version}" - return { - "version": docs_version, - "base_url": base_url, - "chat_apps_url": f"{base_url}/getting-started/chat-apps", - "latest_url": _DOCS_LATEST_URL, - } +# Preserve established direct imports of focused helpers. +_docs_version = system.docs_version +_parse_bool = contracts.parse_bool +_query_first = contracts.query_first +_query_first_alias = contracts.query_first_alias +_query_has_alias = contracts.query_has_alias +_model_catalog_kind = models.model_catalog_kind +_oauth_provider_status = models.oauth_provider_status +_provider_requires_api_key = models.provider_requires_api_key +_reasoning_effort_values_for = models.reasoning_effort_values_for _RUNTIME_CAPABILITIES = { @@ -90,7 +46,6 @@ _RUNTIME_CAPABILITIES = { "can_open_logs": False, "can_export_diagnostics": False, } - _NATIVE_RUNTIME_CAPABILITIES = { **_RUNTIME_CAPABILITIES, "can_restart_engine": True, @@ -98,7 +53,6 @@ _NATIVE_RUNTIME_CAPABILITIES = { "can_open_logs": True, "can_export_diagnostics": True, } - _BROWSER_RESTART_BEHAVIOR_BY_SECTION = { "appearance": "none", "models": "none", @@ -109,7 +63,6 @@ _BROWSER_RESTART_BEHAVIOR_BY_SECTION = { "apps": "engineRestart", "advanced": "appRestart", } - _NATIVE_RESTART_BEHAVIOR_BY_SECTION = { **_BROWSER_RESTART_BEHAVIOR_BY_SECTION, "runtime": "engineRestart", @@ -118,35 +71,6 @@ _NATIVE_RESTART_BEHAVIOR_BY_SECTION = { "apps": "engineRestart", } -_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS -_WEB_SEARCH_PROVIDER_BY_NAME = { - provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS -} - -_IMAGE_GENERATION_ASPECT_RATIOS = { - "1:1", - "3:4", - "9:16", - "4:3", - "16:9", - "3:2", - "2:3", - "21:9", -} -_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576} -_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"} -_WEBUI_OAUTH_TIMEOUT_S = 600 -_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") -_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") - -class WebUISettingsError(ValueError): - """User-facing settings validation failure.""" - - def __init__(self, message: str, *, status: int = 400) -> None: - super().__init__(message) - self.message = message - self.status = status - def _load_settings_config(config_path: Path | None) -> Config: return load_config(config_path) if config_path is not None else load_config() @@ -171,7 +95,6 @@ def runtime_capabilities( surface: str | None = "browser", overrides: dict[str, Any] | None = None, ) -> dict[str, bool]: - """Return the capability flags exposed to the WebUI runtime.""" base = ( _NATIVE_RUNTIME_CAPABILITIES if _normalize_surface(surface) == "native" @@ -231,904 +154,6 @@ def decorate_settings_payload( return result -def _query_first(query: QueryParams, key: str) -> str | None: - values = query.get(key) - return values[0] if values else None - - -def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None: - value = _query_first(query, snake) - return _query_first(query, camel) if value is None else value - - -def _query_has_alias(query: QueryParams, snake: str, camel: str) -> bool: - return snake in query or camel in query - - -def _provider_json_setting( - query: QueryParams, - snake: str, - camel: str, -) -> dict[str, Any] | None: - raw = (_query_first_alias(query, snake, camel) or "").strip() - if not raw: - return None - try: - value: object = json.loads(raw) - except json.JSONDecodeError as exc: - raise WebUISettingsError(f"{snake} must be a JSON object") from exc - if not isinstance(value, dict): - raise WebUISettingsError(f"{snake} must be a JSON object") - return cast(dict[str, Any], value) or None - - -_REDACTED_PROVIDER_SECRET = "••••••••" -_PROVIDER_STRUCTURED_FIELDS = ("extra_headers", "extra_body", "extra_query") -_PROVIDER_SECRET_KEYS = frozenset({ - "auth", - "authentication", - "authorization", - "bearer", - "cookie", - "credential", - "credentials", - "hmac", - "key", - "passphrase", - "passwd", - "proxyauthorization", - "setcookie", - "sig", - "signature", -}) -_PROVIDER_SECRET_KEY_SUFFIXES = ( - "accesskey", - "apikey", - "encryptionkey", - "password", - "privatekey", - "secret", - "secretkey", - "signingkey", - "subscriptionkey", - "token", -) - - -def _provider_setting_key_is_secret(key: str) -> bool: - compact = re.sub(r"[^a-z0-9]", "", key.lower()) - return compact in _PROVIDER_SECRET_KEYS or compact.endswith(_PROVIDER_SECRET_KEY_SUFFIXES) - - -def _redact_provider_secret_values(value: Any, *, secret: bool = False) -> Any: - if secret and value not in (None, ""): - return _REDACTED_PROVIDER_SECRET - if isinstance(value, dict): - value_mapping = cast(dict[str, Any], value) - return { - key: _redact_provider_secret_values( - item, - secret=_provider_setting_key_is_secret(key), - ) - for key, item in value_mapping.items() - } - if isinstance(value, list): - return [ - _redact_provider_secret_values(item) - for item in cast(list[Any], value) - ] - return value - - -def _restore_redacted_provider_secret_values( - submitted: Any, - current: Any, - *, - secret: bool = False, -) -> Any: - if secret and submitted == _REDACTED_PROVIDER_SECRET: - return current - if isinstance(submitted, dict): - submitted_mapping = cast(dict[str, Any], submitted) - current_mapping = cast(dict[str, Any], current) if isinstance(current, dict) else {} - return { - key: _restore_redacted_provider_secret_values( - item, - current_mapping.get(key), - secret=_provider_setting_key_is_secret(key), - ) - for key, item in submitted_mapping.items() - } - if isinstance(submitted, list): - submitted_items = cast(list[Any], submitted) - current_items = cast(list[Any], current) if isinstance(current, list) else [] - return [ - _restore_redacted_provider_secret_values( - item, - current_items[index] if index < len(current_items) else None, - ) - for index, item in enumerate(submitted_items) - ] - return submitted - - -def _provider_config_updates(query: QueryParams) -> dict[str, Any]: - updates: dict[str, Any] = {} - string_fields = ( - ("api_key", "apiKey"), - ("api_base", "apiBase"), - ("api_type", "apiType"), - ("proxy", "proxy"), - ("thinking_style", "thinkingStyle"), - ("region", "region"), - ("profile", "profile"), - ("display_name", "displayName"), - ) - for snake, camel in string_fields: - if _query_has_alias(query, snake, camel): - value = (_query_first_alias(query, snake, camel) or "").strip() - updates[snake] = value or ("auto" if snake == "api_type" else None) - - for snake, camel in ( - ("extra_headers", "extraHeaders"), - ("extra_body", "extraBody"), - ("extra_query", "extraQuery"), - ): - if _query_has_alias(query, snake, camel): - updates[snake] = _provider_json_setting(query, snake, camel) - return updates - - -def _validated_provider_config( - provider_config: ProviderConfig | None, - updates: dict[str, Any], -) -> ProviderConfig: - config_type = type(provider_config) if provider_config is not None else ProviderConfig - values = provider_config.model_dump(mode="python") if provider_config is not None else {} - if provider_config is not None: - for field in _PROVIDER_STRUCTURED_FIELDS: - if field in updates: - updates[field] = _restore_redacted_provider_secret_values( - updates[field], - getattr(provider_config, field), - ) - values.update(updates) - try: - return config_type.model_validate(values) - except ValueError as exc: - errors_callback = getattr(exc, "errors", None) - errors: list[dict[str, Any]] = ( - cast(Any, errors_callback)() - if callable(errors_callback) - else [] - ) - if errors: - error = errors[0] - field = ".".join(str(part) for part in error.get("loc", ())) - message = str(error.get("msg", "invalid value")) - raise WebUISettingsError(f"{field}: {message}" if field else message) from exc - raise WebUISettingsError(str(exc)) from exc - - -def _mask_secret_hint(secret: str | None) -> str | None: - if not secret: - return None - if len(secret) <= 8: - return "••••" - return f"{secret[:4]}••••{secret[-4:]}" - - -def _resolve_env_placeholders(value: str | None) -> str | None: - if not value: - return None - missing = False - - def replace(match: re.Match[str]) -> str: - nonlocal missing - env_value = os.environ.get(match.group(1)) - if env_value is None: - missing = True - return "" - return env_value - - resolved = _ENV_REF_RE.sub(replace, value).strip() - if missing and not resolved: - return None - return resolved or None - - -def _provider_requires_api_key(spec: Any) -> bool: - if spec.name == "azure_openai": - return False - if spec.is_oauth: - return False - if spec.is_local or spec.is_direct: - return False - return True - - -def _provider_requires_api_base(spec: Any) -> bool: - if spec.name == "azure_openai": - return True - return bool(spec.backend == "openai_compat" and spec.is_direct and not spec.default_api_base) - - -def _oauth_provider_status(spec: Any) -> dict[str, Any]: - if not getattr(spec, "is_oauth", False): - return {"configured": False, "account": None, "expires_at": None, "login_supported": False} - - if spec.name == "openai_codex": - try: - from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER - from oauth_cli_kit.storage import FileTokenStorage - except Exception: - return { - "configured": False, - "account": None, - "expires_at": None, - "login_supported": False, - } - token = None - with suppress(Exception): - token = FileTokenStorage( - token_filename=OPENAI_CODEX_PROVIDER.token_filename, - ).load() - expires_at = getattr(token, "expires", None) if token else None - now_ms = int(time.time() * 1000) - return { - "configured": bool( - token - and token.access - and (getattr(token, "refresh", None) or (expires_at and expires_at > now_ms)) - ), - "account": getattr(token, "account_id", None) if token else None, - "expires_at": expires_at, - "login_supported": True, - } - - if spec.name == "github_copilot": - try: - from nanobot.providers.github_copilot_provider import get_github_copilot_login_status - except Exception: - return { - "configured": False, - "account": None, - "expires_at": None, - "login_supported": False, - } - token = None - with suppress(Exception): - token = get_github_copilot_login_status() - return { - "configured": bool(token and token.access and token.expires > int(time.time() * 1000)), - "account": getattr(token, "account_id", None) if token else None, - "expires_at": getattr(token, "expires", None) if token else None, - "login_supported": True, - } - - if spec.name == "xai_grok": - try: - from nanobot.providers.xai_oauth import get_xai_oauth_login_status - except Exception: - return { - "configured": False, - "account": None, - "expires_at": None, - "login_supported": False, - } - token = None - with suppress(Exception): - token = get_xai_oauth_login_status() - expires_at = getattr(token, "expires", None) if token else None - now_ms = int(time.time() * 1000) - return { - "configured": bool( - token - and token.access - and (getattr(token, "refresh", None) or (expires_at and expires_at > now_ms)) - ), - "account": getattr(token, "account_id", None) if token else None, - "expires_at": expires_at, - "login_supported": True, - } - - return {"configured": False, "account": None, "expires_at": None, "login_supported": False} - - -def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool: - if spec.is_oauth: - return bool(_oauth_provider_status(spec)["configured"]) - if _provider_requires_api_base(spec): - return bool(provider_config.api_base) - if _provider_requires_api_key(spec): - return bool(provider_config.api_key) - return bool( - provider_config.api_key - or provider_config.api_base - or getattr(provider_config, "region", None) - or getattr(provider_config, "profile", None) - ) - - -def _dynamic_provider_items(config: Config) -> list[tuple[str, ProviderConfig]]: - model_extra = config.providers.model_extra or {} - return [ - (name, provider_config) - for name, provider_config in model_extra.items() - if isinstance(provider_config, ProviderConfig) - ] - - -def _resolve_settings_provider( - config: Config, - provider_name: str, -) -> tuple[Any, str, ProviderConfig] | None: - spec = find_by_name(provider_name) - if spec is not None: - provider_config = getattr(config.providers, spec.name, None) - if isinstance(provider_config, ProviderConfig): - return spec, spec.name, provider_config - return None - - normalized = provider_name.replace("-", "_") - for extra_name, provider_config in _dynamic_provider_items(config): - if provider_name == extra_name or normalized == extra_name.replace("-", "_"): - return ( - create_dynamic_spec( - extra_name, - display_name=provider_config.display_name or "", - thinking_style=provider_config.thinking_style or "", - ), - extra_name, - provider_config, - ) - return None - - -def _provider_advanced_field_names(name: str, spec: Any) -> list[str]: - fields: list[str] = [] - if spec.backend in {"openai_compat", "anthropic"}: - fields.append("extra_headers") - if spec.backend in {"openai_compat", "bedrock", "openai_codex", "xai_grok"}: - fields.append("extra_body") - if spec.backend == "openai_compat": - fields.extend(("extra_query", "proxy")) - if spec.name in _OAUTH_PROXY_PROVIDERS and "proxy" not in fields: - fields.append("proxy") - if spec.name == "openai": - fields.append("api_type") - if spec.backend == "bedrock": - fields.extend(("region", "profile")) - if find_by_name(name) is None: - fields.append("thinking_style") - return fields - - -def _provider_settings_row( - name: str, - spec: Any, - provider_config: ProviderConfig, -) -> dict[str, Any]: - oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None - is_custom = find_by_name(name) is None - - row = { - "name": name, - "label": spec.label, - "is_custom": is_custom, - "configured": ( - bool(oauth_status["configured"]) - if oauth_status is not None - else _provider_configured_for_settings(spec, provider_config) - ), - "auth_type": "oauth" if spec.is_oauth else "api_key", - "api_key_required": _provider_requires_api_key(spec), - "api_key_hint": _mask_secret_hint(provider_config.api_key), - "api_base": provider_config.api_base, - "default_api_base": spec.default_api_base or None, - "model_selectable": not spec.is_transcription_only, - "model_catalog": _model_catalog_kind(spec), - "advanced_fields": _provider_advanced_field_names(name, spec), - "extra_headers": _redact_provider_secret_values(provider_config.extra_headers), - "extra_body": _redact_provider_secret_values(provider_config.extra_body), - "extra_query": _redact_provider_secret_values(provider_config.extra_query), - "thinking_style": provider_config.thinking_style, - "region": getattr(provider_config, "region", None), - "profile": getattr(provider_config, "profile", None), - "proxy": provider_config.proxy, - } - if oauth_status is not None: - row["oauth_account"] = oauth_status["account"] - row["oauth_expires_at"] = oauth_status["expires_at"] - row["oauth_login_supported"] = oauth_status["login_supported"] - if spec.name == "openai": - row["api_type"] = provider_config.api_type - return row - - -def _provider_settings_rows(config: Config, selected_provider: str | None) -> list[dict[str, Any]]: - """Return one Settings row per provider family while preserving legacy configs.""" - aliases: dict[str, list[Any]] = {} - for spec in PROVIDERS: - if spec.settings_alias_for: - aliases.setdefault(spec.settings_alias_for, []).append(spec) - - rows: list[dict[str, Any]] = [] - for canonical in PROVIDERS: - if canonical.settings_alias_for: - continue - candidates = [canonical, *aliases.get(canonical.name, [])] - chosen = next((spec for spec in candidates if spec.name == selected_provider), None) - if chosen is None: - chosen = next( - ( - spec - for spec in candidates - if (provider_config := getattr(config.providers, spec.name, None)) is not None - and _provider_configured_for_settings(spec, provider_config) - ), - canonical, - ) - provider_config = getattr(config.providers, chosen.name, None) - if provider_config is None: - continue - row = _provider_settings_row(chosen.name, chosen, provider_config) - row["label"] = canonical.label - rows.append(row) - return rows - - -def _model_catalog_kind(spec: Any) -> str: - catalog = getattr(spec, "model_catalog", "auto") - if catalog != "auto": - return catalog - if spec.is_transcription_only or spec.is_oauth: - return "unsupported" - if spec.backend != "openai_compat" and spec.name != "minimax_anthropic": - return "unsupported" - if spec.is_local: - return "local" - if spec.is_direct: - return "custom" - if spec.is_gateway: - return "catalog" - return "official" - - -def _model_id_from_row(row: Any) -> str | None: - if isinstance(row, str): - return row.strip() or None - if not isinstance(row, dict): - return None - row_mapping = cast(dict[str, Any], row) - for key in ("id", "name", "model"): - value = row_mapping.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - -def _model_context_window(row: Any) -> int | None: - if not isinstance(row, dict): - return None - row_mapping = cast(dict[str, Any], row) - for key in ( - "context_window", - "context_length", - "max_context_length", - "max_model_len", - "max_input_tokens", - ): - value = row_mapping.get(key) - if isinstance(value, int) and value > 0: - return value - if isinstance(value, float) and value > 0: - return int(value) - return None - - -def _model_row_payload(row: Any) -> dict[str, Any] | None: - model_id = _model_id_from_row(row) - if not model_id: - return None - label: str | None = None - description: str | None = None - owned_by: str | None = None - if isinstance(row, dict): - row_mapping = cast(dict[str, Any], row) - raw_label = ( - row_mapping.get("display_name") - or row_mapping.get("label") - or row_mapping.get("name") - ) - if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id: - label = raw_label.strip() - raw_description = row_mapping.get("description") - if isinstance(raw_description, str) and raw_description.strip(): - description = raw_description.strip() - raw_owner = ( - row_mapping.get("owned_by") - or row_mapping.get("owner") - or row_mapping.get("organization") - ) - if isinstance(raw_owner, str) and raw_owner.strip(): - owned_by = raw_owner.strip() - payload = { - "id": model_id, - "label": label, - "owned_by": owned_by, - "context_window": _model_context_window(row), - } - if description: - payload["description"] = description - return payload - - -def _extract_model_rows(body: Any) -> list[dict[str, Any]]: - raw_rows = cast(dict[str, Any], body).get("data") if isinstance(body, dict) else body - if not isinstance(raw_rows, list): - return [] - rows: list[dict[str, Any]] = [] - seen: set[str] = set() - for raw_row in cast(list[object], raw_rows): - row = _model_row_payload(raw_row) - if row is None or row["id"] in seen: - continue - seen.add(row["id"]) - rows.append(row) - return rows - - -def provider_models_payload( - query: QueryParams, - *, - config_path: Path | None = None, -) -> dict[str, Any]: - """Fetch an OpenAI-compatible provider's model list for Settings. - - The result is advisory only: users can always type a custom model id. This - helper deliberately avoids mutating config so probing model lists never - changes runtime behavior. - """ - provider_name = (_query_first(query, "provider") or "").strip() - if not provider_name: - raise WebUISettingsError("provider is required") - - config = _load_settings_config(config_path) - resolved_provider = _resolve_settings_provider(config, provider_name) - if resolved_provider is None: - raise WebUISettingsError("unknown provider") - spec, provider_key, provider_config = resolved_provider - - catalog_kind = _model_catalog_kind(spec) - base_payload: dict[str, Any] = { - "provider": provider_key, - "label": spec.label, - "catalog_kind": catalog_kind, - "models": [], - "model_count": 0, - "message": None, - "fetched_at": time.time(), - } - if catalog_kind == "unsupported": - return { - **base_payload, - "status": "unsupported", - "message": "Model list is not available for this provider. Type a model ID manually.", - } - - if catalog_kind == "builtin": - rows = [ - { - "id": model.id, - "label": model.label or None, - "description": model.description or None, - "owned_by": spec.label, - "context_window": model.context_window, - } - for model in spec.builtin_models - ] - return { - **base_payload, - "status": "available", - "models": rows, - "model_count": len(rows), - } - - api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base - if spec.name == "openai" and not api_base: - api_base = "https://api.openai.com/v1" - if not api_base: - return { - **base_payload, - "status": "missing_api_base", - "message": "Configure an API base URL to load models.", - } - - api_key = _resolve_env_placeholders(provider_config.api_key) - if _provider_requires_api_key(spec) and not api_key: - return { - **base_payload, - "status": "not_configured", - "message": "Configure this provider before loading models.", - } - - headers = {"Accept": "application/json"} - if api_key: - if spec.name == "minimax_anthropic": - headers["X-Api-Key"] = api_key - else: - headers["Authorization"] = f"Bearer {api_key}" - - models_url = f"{api_base.rstrip('/')}/models" - if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"): - models_url = f"{api_base.rstrip('/')}/v1/models" - - try: - response = httpx.get( - models_url, - headers=headers, - timeout=10.0, - follow_redirects=False, - ) - response.raise_for_status() - rows = _extract_model_rows(response.json()) - except httpx.HTTPStatusError as exc: - status = exc.response.status_code - if status in {401, 403}: - return { - **base_payload, - "status": "not_configured", - "message": "The provider rejected the configured credential.", - } - return { - **base_payload, - "status": "error", - "message": f"Model list request failed with HTTP {status}.", - } - except (httpx.HTTPError, ValueError) as exc: - return { - **base_payload, - "status": "error", - "message": f"Could not load models: {exc}", - } - - return { - **base_payload, - "status": "available", - "models": rows, - "model_count": len(rows), - } - - -def _parse_bool(value: str, field: str) -> bool: - normalized = value.strip().lower() - if normalized not in {"1", "0", "true", "false", "yes", "no"}: - raise WebUISettingsError(f"{field} must be boolean") - return normalized in {"1", "true", "yes"} - - -def _parse_context_window_tokens(value: str | None) -> int | None: - if value is None: - return None - try: - parsed = int(value) - except ValueError: - raise WebUISettingsError("context_window_tokens must be an integer") from None - if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS: - raise WebUISettingsError( - "context_window_tokens must be 65536, 200000, 262144, 500000, or 1048576" - ) - return parsed - - -def _parse_positive_int(value: str | None, field: str) -> int | None: - if value is None: - return None - try: - parsed = int(value) - except ValueError: - raise WebUISettingsError(f"{field} must be an integer") from None - if parsed <= 0: - raise WebUISettingsError(f"{field} must be greater than zero") - return parsed - - -def _parse_temperature(value: str | None) -> float | None: - if value is None: - return None - try: - parsed = float(value) - except ValueError: - raise WebUISettingsError("temperature must be a number") from None - if not math.isfinite(parsed) or parsed < 0 or parsed > 2: - raise WebUISettingsError("temperature must be between 0 and 2") - return parsed - - -def _model_configuration_slug(label: str) -> str: - normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower()) - normalized = normalized.strip("-_") - if not normalized: - raise WebUISettingsError("configuration name is required") - if normalized == "default": - raise WebUISettingsError("configuration name is reserved") - if len(normalized) > 48: - normalized = normalized[:48].rstrip("-_") - return normalized - - -def _custom_provider_key(config: Config, display_name: str) -> str: - slug = _MODEL_CONFIGURATION_SLUG_RE.sub("-", display_name.strip().lower()).strip("-_") - base = f"custom-{slug or 'provider'}" - if len(base) > 56: - base = base[:56].rstrip("-_") - existing = { - name.replace("_", "-").lower() - for name, _provider_config in _dynamic_provider_items(config) - } - candidate = base - suffix = 2 - while candidate.replace("_", "-").lower() in existing or find_by_name(candidate): - candidate = f"{base}-{suffix}" - suffix += 1 - return candidate - - -def _provider_display_name_exists( - config: Config, - display_name: str, - *, - exclude_key: str | None = None, -) -> bool: - normalized = display_name.strip().casefold() - if any(spec.label.strip().casefold() == normalized for spec in PROVIDERS): - return True - for provider_key, provider_config in _dynamic_provider_items(config): - if provider_key == exclude_key: - continue - label = ( - provider_config.display_name - or provider_key.replace("-", " ").replace("_", " ").title() - ) - if label.strip().casefold() == normalized: - return True - return False - - -def _unique_model_configuration_name(config: Config, label: str) -> str: - """Return a stable, unused preset name for a migrated model configuration.""" - try: - base = _model_configuration_slug(label) - except WebUISettingsError: - base = "model" - candidate = base - suffix = 2 - while candidate in config.model_presets: - candidate = f"{base}-{suffix}" - suffix += 1 - return candidate - - -def _model_configuration_label(model: str) -> str: - return model.rsplit("/", 1)[-1] or model - - -def _model_call_order_state(config: Config) -> tuple[list[str], bool]: - defaults = config.agents.defaults - primary = defaults.model_preset - if not primary or primary == "default" or primary not in config.model_presets: - return [], False - order = [primary] - for fallback in defaults.fallback_models: - if not isinstance(fallback, str): - return [], False - order.append(fallback) - return order, True - - -def _validate_configured_provider(config: Config, provider: str) -> None: - if provider == "auto": - return - resolved_provider = _resolve_settings_provider(config, provider) - if resolved_provider is None: - raise WebUISettingsError("unknown provider") - spec, _, provider_config = resolved_provider - if spec.is_transcription_only: - raise WebUISettingsError("provider does not support chat models") - if not _provider_configured_for_settings(spec, provider_config): - raise WebUISettingsError("provider is not configured") - - -def _image_generation_provider_rows(config: Config) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for name in image_gen_provider_names(): - image_provider = get_image_gen_provider(name) - spec = find_by_name(name) - provider_config = getattr(config.providers, name, None) - configured = ( - _provider_configured_for_settings(spec, provider_config) - if spec is not None and provider_config is not None - else bool(getattr(provider_config, "api_key", None)) - ) - rows.append( - { - "name": name, - "label": spec.label if spec is not None else name, - "configured": configured, - "auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key", - "api_key_hint": _mask_secret_hint( - getattr(provider_config, "api_key", None) - ), - "api_base": getattr(provider_config, "api_base", None), - "default_api_base": ( - spec.default_api_base if spec and spec.default_api_base else None - ), - "models": list(image_provider.model_options) if image_provider else [], - "default_model": ( - image_provider.model_options[0] - if image_provider and image_provider.model_options - else None - ), - } - ) - return rows - - -_DEFAULT_REASONING_EFFORT_VALUES: tuple[str, ...] = ("", "low", "medium", "high") - - -def _reasoning_effort_values_for(provider_name: str, model: str) -> list[str]: - """Return user-facing reasoning_effort options for this provider+model. - - Mistral chat models accept only "high"/"none"; Magistral rejects the - kwarg entirely (reasoning is implicit). For everyone else, return the - full OpenAI vocab. - """ - spec = find_by_name(provider_name) if provider_name else None - if spec is None: - return list(_DEFAULT_REASONING_EFFORT_VALUES) - - model_lower = (model or "").lower() - if model_lower.rsplit("/", 1)[-1] == "kimi-k3": - # K3 always reasons and currently exposes only its default/max effort. - return ["", "max"] - - implicit = getattr(spec, "implicit_reasoning_models", ()) - if implicit and any(pat in model_lower for pat in implicit): - # Reasoning is always on; only "Default" makes sense. - return [""] - - remap = getattr(spec, "reasoning_effort_remap", ()) - if remap: - # Reverse the remap: surface the distinct wire-vocab outputs as the - # user's options. Mistral collapses to "high"/"none" → UI shows - # "Default" + "High". - wire_values: list[str] = [] - for _user_val, wire_val in remap: - if wire_val and wire_val != "none" and wire_val not in wire_values: - wire_values.append(wire_val) - return ["", *wire_values] - - return list(_DEFAULT_REASONING_EFFORT_VALUES) - - -def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for name in transcription_provider_names(): - spec = find_by_name(name) - provider_config = getattr(config.providers, name, None) - rows.append({ - "name": name, - "label": spec.label if spec is not None else name, - "configured": bool(getattr(provider_config, "api_key", None)), - "api_key_hint": _mask_secret_hint(getattr(provider_config, "api_key", None)), - "api_base": getattr(provider_config, "api_base", None), - "default_api_base": spec.default_api_base if spec and spec.default_api_base else None, - }) - return rows - - def settings_payload( *, requires_restart: bool = False, @@ -1139,218 +164,21 @@ def settings_payload( config_path: Path | None = None, ) -> dict[str, Any]: config = _load_settings_config(config_path) - defaults = config.agents.defaults - active_preset_name = defaults.model_preset or "default" - effective_preset = config.resolve_preset() - - provider_name = ( - config.get_provider_name(effective_preset.model, preset=effective_preset) - or effective_preset.provider - ) - provider = config.get_provider(effective_preset.model, preset=effective_preset) - selected_provider = provider_name - if effective_preset.provider != "auto": - spec = find_by_name(effective_preset.provider) - selected_provider = spec.name if spec else provider_name - - providers = _provider_settings_rows(config, selected_provider) - for provider_key, provider_config in _dynamic_provider_items(config): - providers.append( - _provider_settings_row( - provider_key, - create_dynamic_spec( - provider_key, - display_name=provider_config.display_name or "", - thinking_style=provider_config.thinking_style or "", - ), - provider_config, - ) - ) - - search_config = config.tools.web.search - image_config = config.tools.image_generation - transcription = resolve_transcription_config(config) - search_provider = ( - search_config.provider - if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME - else "duckduckgo" - ) - image_providers = _image_generation_provider_rows(config) - selected_image_provider = next( - ( - provider - for provider in image_providers - if provider["name"] == image_config.provider + payload: dict[str, Any] = { + **models.model_settings_payload( + config, + oauth_status=_oauth_provider_status, + ), + **capabilities.capability_settings_payload( + config, + oauth_status=_oauth_provider_status, + ), + **system.system_settings_payload( + config, + config_path=_settings_config_path(config_path), + version=__version__, ), - None, - ) - model_presets = [ - { - "name": "default", - "label": "Default", - "active": active_preset_name == "default", - "is_default": True, - "model": defaults.model, - "provider": defaults.provider, - "resolved_provider": config.get_provider_name( - defaults.model, - preset=config.resolve_default_preset(), - ), - "max_tokens": defaults.max_tokens, - "context_window_tokens": defaults.context_window_tokens, - "temperature": defaults.temperature, - "reasoning_effort": defaults.reasoning_effort, - "reasoning_effort_values": _reasoning_effort_values_for( - config.get_provider_name( - defaults.model, - preset=config.resolve_default_preset(), - ) - or defaults.provider, - defaults.model, - ), - } - ] - for name, preset in config.model_presets.items(): - resolved_preset_provider = ( - config.get_provider_name( - preset.model, - preset=preset, - ) - or preset.provider - ) - model_presets.append( - { - "name": name, - "label": preset.label or name, - "active": active_preset_name == name, - "is_default": False, - "model": preset.model, - "provider": preset.provider, - "resolved_provider": resolved_preset_provider, - "max_tokens": preset.max_tokens, - "context_window_tokens": preset.context_window_tokens, - "temperature": preset.temperature, - "reasoning_effort": preset.reasoning_effort, - "reasoning_effort_values": _reasoning_effort_values_for( - resolved_preset_provider, preset.model - ), - } - ) - - model_call_order, model_call_order_editable = _model_call_order_state(config) - exec_config = config.tools.exec - sandbox_status = workspace_sandbox_status( - restrict_to_workspace=config.tools.restrict_to_workspace, - workspace=config.workspace_path, - ) - payload = { - "agent": { - "model": effective_preset.model, - "provider": selected_provider, - "resolved_provider": provider_name, - "has_api_key": bool(provider and provider.api_key), - "model_preset": active_preset_name, - "max_tokens": effective_preset.max_tokens, - "context_window_tokens": effective_preset.context_window_tokens, - "temperature": effective_preset.temperature, - "reasoning_effort": effective_preset.reasoning_effort, - "timezone": defaults.timezone, - "tool_hint_max_length": defaults.tool_hint_max_length, - }, - "model_presets": model_presets, - "model_call_order": model_call_order, - "model_call_order_editable": model_call_order_editable, - "providers": providers, - "web_search": { - "provider": search_provider, - "api_key_hint": _mask_secret_hint(search_config.api_key), - "base_url": search_config.base_url or None, - "max_results": search_config.max_results, - "timeout": search_config.timeout, - "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS), - }, - "web": { - "enable": config.tools.web.enable, - "proxy": config.tools.web.proxy, - "user_agent": config.tools.web.user_agent, - "search": { - "max_results": search_config.max_results, - "timeout": search_config.timeout, - }, - "fetch": { - "use_jina_reader": config.tools.web.fetch.use_jina_reader, - }, - }, - "api": { - "host": config.api.host, - "port": config.api.port, - "timeout": config.api.timeout, - "api_key_hint": _mask_secret_hint(config.api.api_key), - }, - "observability": { - "provider": "langfuse", - "configured": bool( - os.environ.get("LANGFUSE_SECRET_KEY") - and os.environ.get("LANGFUSE_PUBLIC_KEY") - ), - "base_url": os.environ.get("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com", - }, - "image_generation": { - "enabled": image_config.enabled, - "provider": image_config.provider, - "provider_configured": bool( - selected_image_provider and selected_image_provider["configured"] - ), - "model": image_config.model, - "default_aspect_ratio": image_config.default_aspect_ratio, - "default_image_size": image_config.default_image_size, - "max_images_per_turn": image_config.max_images_per_turn, - "save_dir": image_config.save_dir, - "providers": image_providers, - }, - "transcription": { - "enabled": transcription.enabled, - "provider": transcription.provider, - "provider_configured": transcription.configured, - "model": transcription.model, - "language": transcription.language, - "max_duration_sec": transcription.max_duration_sec, - "max_upload_mb": transcription.max_upload_mb, - "providers": _transcription_provider_rows(config), - }, - "runtime": { - "config_path": str(_settings_config_path(config_path).expanduser()), - "workspace_path": str(config.workspace_path), - "gateway_host": config.gateway.host, - "gateway_port": config.gateway.port, - "heartbeat": { - "enabled": config.gateway.heartbeat.enabled, - "interval_s": config.gateway.heartbeat.interval_s, - "keep_recent_messages": config.gateway.heartbeat.keep_recent_messages, - }, - "dream": { - "schedule": defaults.dream.describe_schedule(), - }, - "unified_session": defaults.unified_session, - }, - "usage": token_usage_payload(timezone_name=defaults.timezone), - "advanced": { - "restrict_to_workspace": config.tools.restrict_to_workspace, - "workspace_sandbox": sandbox_status.as_dict(), - "webui_allow_local_service_access": config.tools.webui_allow_local_service_access, - "allow_local_preview_access": config.tools.webui_allow_local_service_access, - "webui_default_access_mode": read_webui_default_access_mode(), - "private_service_protection_enabled": True, - "ssrf_whitelist_count": len(config.tools.ssrf_whitelist), - "mcp_server_count": len(config.tools.mcp_servers), - "exec_enabled": exec_config.enable, - "exec_sandbox": exec_config.sandbox or None, - "exec_path_prepend_set": bool(exec_config.path_prepend), - "exec_path_append_set": bool(exec_config.path_append), - }, "requires_restart": requires_restart, - "version": _version_payload(), - "docs": _docs_payload(), } return decorate_settings_payload( payload, @@ -1362,9 +190,7 @@ def settings_payload( def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]: - """Return the lightweight token usage slice for Overview refreshes.""" - config = _load_settings_config(config_path) - return token_usage_payload(timezone_name=config.agents.defaults.timezone) + return system.settings_usage_payload(_load_settings_config(config_path)) def update_agent_settings( @@ -1373,84 +199,18 @@ def update_agent_settings( config_path: Path | None = None, ) -> dict[str, Any]: config = _load_settings_config(config_path) - defaults = config.agents.defaults - changed = False - restart_required = False - - if "model_preset" in query or "modelPreset" in query: - preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip() - preset_value = None if not preset or preset == "default" else preset - if preset_value is not None and preset_value not in config.model_presets: - raise WebUISettingsError("unknown model preset") - if defaults.model_preset != preset_value: - defaults.model_preset = preset_value - changed = True - - model = _query_first(query, "model") - if model is not None: - model = model.strip() - if not model: - raise WebUISettingsError("model is required") - if defaults.model != model: - defaults.model = model - changed = True - - provider = _query_first(query, "provider") - if provider is not None: - provider = provider.strip() - if not provider: - raise WebUISettingsError("provider is required") - _validate_configured_provider(config, provider) - if defaults.provider != provider: - defaults.provider = provider - changed = True - - context_window_tokens = _parse_context_window_tokens( - _query_first_alias(query, "context_window_tokens", "contextWindowTokens") - ) - if ( - context_window_tokens is not None - and defaults.context_window_tokens != context_window_tokens - ): - defaults.context_window_tokens = context_window_tokens - changed = True - - timezone = _query_first(query, "timezone") - if timezone is not None: - timezone = timezone.strip() - if not timezone: - raise WebUISettingsError("timezone is required") - try: - ZoneInfo(timezone) - except Exception: - raise WebUISettingsError("invalid timezone") from None - timezone_changed = defaults.timezone != timezone - if timezone_changed or defaults.timezone_mode != "manual": - defaults.timezone = timezone - defaults.timezone_mode = "manual" - changed = True - restart_required = timezone_changed - - tool_hint_max_length = _query_first_alias( + model_changed = models.update_agent_model_settings( + config, query, - "tool_hint_max_length", - "toolHintMaxLength", + oauth_status=_oauth_provider_status, ) - if tool_hint_max_length is not None: - try: - parsed = int(tool_hint_max_length) - except ValueError: - raise WebUISettingsError("tool_hint_max_length must be an integer") from None - if parsed < 20 or parsed > 500: - raise WebUISettingsError("tool_hint_max_length must be between 20 and 500") - if defaults.tool_hint_max_length != parsed: - defaults.tool_hint_max_length = parsed - changed = True - restart_required = True - - if changed: + system_changed, restart_required = system.update_agent_system_settings(config, query) + if model_changed or system_changed: _save_settings_config(config, config_path) - return settings_payload(requires_restart=restart_required, config_path=config_path) + return settings_payload( + requires_restart=restart_required, + config_path=config_path, + ) def create_model_configuration( @@ -1458,51 +218,11 @@ def create_model_configuration( *, config_path: Path | None = None, ) -> dict[str, Any]: - label = (_query_first_alias(query, "label", "displayName") or "").strip() - raw_name = (_query_first(query, "name") or label).strip() - model = (_query_first(query, "model") or "").strip() - provider = (_query_first(query, "provider") or "").strip() - - if not label: - label = raw_name - if not model: - raise WebUISettingsError("model is required") - if not provider: - raise WebUISettingsError("provider is required") - - name = _model_configuration_slug(raw_name or label) config = _load_settings_config(config_path) - if name in config.model_presets: - raise WebUISettingsError("configuration already exists", status=409) - _validate_configured_provider(config, provider) - - base = config.resolve_preset() - max_tokens = _parse_positive_int( - _query_first_alias(query, "max_tokens", "maxTokens"), - "max_tokens", - ) - context_window_tokens = _parse_positive_int( - _query_first_alias(query, "context_window_tokens", "contextWindowTokens"), - "context_window_tokens", - ) - temperature = _parse_temperature(_query_first(query, "temperature")) - reasoning_effort = base.reasoning_effort - if "reasoning_effort" in query or "reasoningEffort" in query: - reasoning_effort = ( - _query_first_alias(query, "reasoning_effort", "reasoningEffort") or "" - ).strip() or None - config.model_presets[name] = ModelPresetConfig( - label=label, - model=model, - provider=provider, - max_tokens=max_tokens if max_tokens is not None else base.max_tokens, - context_window_tokens=( - context_window_tokens - if context_window_tokens is not None - else base.context_window_tokens - ), - temperature=temperature if temperature is not None else base.temperature, - reasoning_effort=reasoning_effort, + name = models.create_model_configuration( + config, + query, + oauth_status=_oauth_provider_status, ) _save_settings_config(config, config_path) payload = settings_payload(config_path=config_path) @@ -1515,77 +235,12 @@ def update_model_configuration( *, config_path: Path | None = None, ) -> dict[str, Any]: - name = (_query_first(query, "name") or "").strip() - if not name or name == "default": - raise WebUISettingsError("model configuration is required") - config = _load_settings_config(config_path) - preset = config.model_presets.get(name) - if preset is None: - raise WebUISettingsError("unknown model configuration") - - changed = False - label = _query_first_alias(query, "label", "displayName") - if label is not None: - label = label.strip() - if not label: - raise WebUISettingsError("label is required") - if preset.label != label: - preset.label = label - changed = True - - model = _query_first(query, "model") - if model is not None: - model = model.strip() - if not model: - raise WebUISettingsError("model is required") - if preset.model != model: - preset.model = model - changed = True - - provider = _query_first(query, "provider") - if provider is not None: - provider = provider.strip() - if not provider: - raise WebUISettingsError("provider is required") - _validate_configured_provider(config, provider) - if preset.provider != provider: - preset.provider = provider - changed = True - - context_window_tokens = _parse_positive_int( - _query_first_alias(query, "context_window_tokens", "contextWindowTokens"), - "context_window_tokens", - ) - if ( - context_window_tokens is not None - and preset.context_window_tokens != context_window_tokens + if models.update_model_configuration( + config, + query, + oauth_status=_oauth_provider_status, ): - preset.context_window_tokens = context_window_tokens - changed = True - - max_tokens = _parse_positive_int( - _query_first_alias(query, "max_tokens", "maxTokens"), - "max_tokens", - ) - if max_tokens is not None and preset.max_tokens != max_tokens: - preset.max_tokens = max_tokens - changed = True - - temperature = _parse_temperature(_query_first(query, "temperature")) - if temperature is not None and preset.temperature != temperature: - preset.temperature = temperature - changed = True - - if "reasoning_effort" in query or "reasoningEffort" in query: - reasoning_effort = ( - _query_first_alias(query, "reasoning_effort", "reasoningEffort") or "" - ).strip() or None - if preset.reasoning_effort != reasoning_effort: - preset.reasoning_effort = reasoning_effort - changed = True - - if changed: _save_settings_config(config, config_path) return settings_payload(config_path=config_path) @@ -1595,46 +250,8 @@ def update_model_call_order( *, config_path: Path | None = None, ) -> dict[str, Any]: - raw_order = _query_first_alias(query, "order", "presetNames") - if raw_order is None: - raise WebUISettingsError("model call order is required") - try: - order: object = json.loads(raw_order) - except json.JSONDecodeError: - raise WebUISettingsError("model call order must be a JSON array") from None - if ( - not isinstance(order, list) - or not order - or any( - not isinstance(name, str) or not name.strip() - for name in cast(list[object], order) - ) - ): - raise WebUISettingsError("model call order must contain at least one preset") - - normalized_order = [ - cast(str, name).strip() - for name in cast(list[object], order) - ] config = _load_settings_config(config_path) - _, editable = _model_call_order_state(config) - if not editable: - raise WebUISettingsError( - "convert the existing model configuration to presets first", - status=409, - ) - unknown = [name for name in normalized_order if name not in config.model_presets] - if unknown: - raise WebUISettingsError(f"unknown model preset: {unknown[0]}") - - defaults = config.agents.defaults - fallback_models: list[FallbackCandidate] = list(normalized_order[1:]) - if ( - defaults.model_preset != normalized_order[0] - or defaults.fallback_models != fallback_models - ): - defaults.model_preset = normalized_order[0] - defaults.fallback_models = fallback_models + if models.update_model_call_order(config, query): _save_settings_config(config, config_path) return settings_payload(config_path=config_path) @@ -1644,60 +261,8 @@ def migrate_model_configurations( *, config_path: Path | None = None, ) -> dict[str, Any]: - """Materialize legacy primary/inline model settings as named presets.""" config = _load_settings_config(config_path) - defaults = config.agents.defaults - primary = config.resolve_preset() - created: list[str] = [] - - if not defaults.model_preset or defaults.model_preset == "default": - label = _model_configuration_label(primary.model) - name = _unique_model_configuration_name(config, label) - config.model_presets[name] = ModelPresetConfig( - label=label, - model=primary.model, - provider=primary.provider, - max_tokens=primary.max_tokens, - context_window_tokens=primary.context_window_tokens, - temperature=primary.temperature, - reasoning_effort=primary.reasoning_effort, - ) - defaults.model_preset = name - created.append(name) - - fallback_models: list[FallbackCandidate] = [] - for fallback in defaults.fallback_models: - if isinstance(fallback, str): - fallback_models.append(fallback) - continue - label = _model_configuration_label(fallback.model) - name = _unique_model_configuration_name(config, label) - config.model_presets[name] = ModelPresetConfig( - label=label, - model=fallback.model, - provider=fallback.provider, - max_tokens=( - fallback.max_tokens - if fallback.max_tokens is not None - else primary.max_tokens - ), - context_window_tokens=( - fallback.context_window_tokens - if fallback.context_window_tokens is not None - else primary.context_window_tokens - ), - temperature=( - fallback.temperature - if fallback.temperature is not None - else primary.temperature - ), - reasoning_effort=fallback.reasoning_effort, - ) - fallback_models.append(name) - created.append(name) - - if created: - defaults.fallback_models = fallback_models + if models.migrate_model_configurations(config): _save_settings_config(config, config_path) return settings_payload(config_path=config_path) @@ -1707,24 +272,8 @@ def delete_model_configuration( *, config_path: Path | None = None, ) -> dict[str, Any]: - name = (_query_first(query, "name") or "").strip() - if not name or name == "default": - raise WebUISettingsError("model configuration is required") - config = _load_settings_config(config_path) - if name not in config.model_presets: - raise WebUISettingsError("unknown model configuration") - defaults = config.agents.defaults - referenced = defaults.model_preset == name or any( - fallback == name for fallback in defaults.fallback_models - ) - if referenced: - raise WebUISettingsError( - "remove the model preset from the call order first", - status=409, - ) - - del config.model_presets[name] + models.delete_model_configuration(config, query) _save_settings_config(config, config_path) return settings_payload(config_path=config_path) @@ -1734,39 +283,8 @@ def create_provider_settings( *, config_path: Path | None = None, ) -> dict[str, Any]: - display_name = (_query_first_alias(query, "name", "displayName") or "").strip() - if not display_name: - raise WebUISettingsError("provider name is required") - if len(display_name) > 80: - raise WebUISettingsError("provider name must be 80 characters or fewer") - updates = _provider_config_updates(query) - allowed = { - "api_key", - "api_base", - "proxy", - "extra_headers", - "extra_body", - "extra_query", - "thinking_style", - "display_name", - } - unsupported = set(updates) - allowed - if unsupported: - field = sorted(unsupported)[0] - raise WebUISettingsError(f"{field} is not supported for a custom provider") - api_base = str(updates.get("api_base") or "") - if not api_base: - raise WebUISettingsError("API base is required") - config = _load_settings_config(config_path) - if _provider_display_name_exists(config, display_name): - raise WebUISettingsError("provider already exists", status=409) - - provider_key = _custom_provider_key(config, display_name) - updates["display_name"] = display_name - updates["api_type"] = "auto" - provider_config = _validated_provider_config(None, updates) - setattr(config.providers, provider_key, provider_config) + provider_key = models.create_provider_settings(config, query) _save_settings_config(config, config_path) payload = settings_payload(config_path=config_path) payload["created_provider"] = provider_key @@ -1778,61 +296,26 @@ def update_provider_settings( *, config_path: Path | None = None, ) -> dict[str, Any]: - provider_name = (_query_first(query, "provider") or "").strip() - if not provider_name: - raise WebUISettingsError("provider is required") - config = _load_settings_config(config_path) - resolved_provider = _resolve_settings_provider(config, provider_name) - if resolved_provider is None: - raise WebUISettingsError("unknown provider") - spec, provider_key, provider_config = resolved_provider - updates = _provider_config_updates(query) - if not spec.is_oauth and spec.name != "openai": - # Preserve the legacy settings API contract: api_type only applies to - # OpenAI, and is ignored when older clients send it for another provider. - updates.pop("api_type", None) - if spec.is_oauth: - if spec.name not in _OAUTH_PROXY_PROVIDERS: - raise WebUISettingsError("unknown provider") - unsupported = set(updates) - {"proxy", "extra_body"} - if unsupported: - raise WebUISettingsError("OAuth provider only supports proxy and extra_body settings") - else: - allowed = { - "api_key", - "api_base", - *_provider_advanced_field_names(provider_key, spec), - } - if find_by_name(provider_key) is None: - allowed.add("display_name") - unsupported = set(updates) - allowed - if unsupported: - field = sorted(unsupported)[0] - raise WebUISettingsError(f"{field} is not supported for this provider") - - if "display_name" in updates: - display_name = str(updates["display_name"] or "") - if not display_name: - raise WebUISettingsError("provider name is required") - if len(display_name) > 80: - raise WebUISettingsError("provider name must be 80 characters or fewer") - if _provider_display_name_exists(config, display_name, exclude_key=provider_key): - raise WebUISettingsError("provider already exists", status=409) - - updated_provider_config = _validated_provider_config(provider_config, updates) - changed = updated_provider_config != provider_config + changed, restart_required = models.update_provider_settings(config, query) if changed: - setattr(config.providers, provider_key, updated_provider_config) _save_settings_config(config, config_path) - image_config = config.tools.image_generation - restart_required = ( - changed - and image_config.enabled - and image_config.provider == provider_key - and get_image_gen_provider(provider_key) is not None + return settings_payload( + requires_restart=restart_required, + config_path=config_path, + ) + + +def provider_models_payload( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + return models.provider_models_payload( + _load_settings_config(config_path), + query, + http_get=httpx.get, ) - return settings_payload(requires_restart=restart_required, config_path=config_path) def login_oauth_provider( @@ -1841,96 +324,13 @@ def login_oauth_provider( oauth_flows: WebUIOAuthFlowRegistry, config_path: Path | None = None, ) -> dict[str, Any]: - provider_name = (_query_first(query, "provider") or "").strip() - if not provider_name: - raise WebUISettingsError("provider is required") - spec = find_by_name(provider_name) - if spec is None or not spec.is_oauth: - raise WebUISettingsError("unknown OAuth provider") - - if spec.name == "openai_codex": - try: - from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login - except ImportError: - raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None - - try: - proxy = resolve_config_env_vars( - _load_settings_config(config_path), - config_path=config_path, - ).providers.openai_codex.proxy or None - except ValueError as e: - raise WebUISettingsError(str(e), status=400) from e - remote_browser_value = _query_first(query, "remote_browser") - remote_browser = ( - _parse_bool(remote_browser_value, "remote_browser") - if remote_browser_value is not None - else False - ) - try: - flow = start_openai_codex_oauth_login( - proxy=proxy, - timeout_s=_WEBUI_OAUTH_TIMEOUT_S, - open_browser=not remote_browser, - ) - except Exception as e: - raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e - flow_id = secrets.token_urlsafe(24) - oauth_flows.register(spec.name, flow_id, flow) - return { - "status": "authorization_required", - "provider": spec.name, - "flow_id": flow_id, - "authorization_url": flow.authorization_url, - "expires_in": flow.remaining_seconds, - "completion_input": "callback_url", - } - - if spec.name == "github_copilot": - try: - from nanobot.providers.github_copilot_provider import ( - get_github_copilot_login_status, - login_github_copilot, - ) - except ImportError: - raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None - - token = get_github_copilot_login_status() - if not token: - token = login_github_copilot(print_fn=lambda _message: None) - if not (token and token.access): - raise WebUISettingsError("OAuth login failed", status=401) - return settings_payload(config_path=config_path) - - if spec.name == "xai_grok": - from nanobot.providers.xai_oauth import start_xai_oauth_login - - try: - proxy = resolve_config_env_vars( - _load_settings_config(config_path), - config_path=config_path, - ).providers.xai_grok.proxy or None - except ValueError as e: - raise WebUISettingsError(str(e), status=400) from e - try: - flow = start_xai_oauth_login( - proxy=proxy, - timeout_s=_WEBUI_OAUTH_TIMEOUT_S, - ) - except Exception as e: - raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e - flow_id = secrets.token_urlsafe(24) - oauth_flows.register(spec.name, flow_id, flow) - return { - "status": "authorization_required", - "provider": spec.name, - "flow_id": flow_id, - "authorization_url": flow.authorization_url, - "expires_in": flow.remaining_seconds, - "completion_input": "authorization_code", - } - - raise WebUISettingsError("OAuth login is not supported for this provider") + return models.login_oauth_provider( + _load_settings_config(config_path), + query, + oauth_flows=oauth_flows, + config_path=config_path, + settings_payload=settings_payload, + ) def complete_oauth_provider( @@ -1940,48 +340,13 @@ def complete_oauth_provider( oauth_flows: WebUIOAuthFlowRegistry, config_path: Path | None = None, ) -> dict[str, Any]: - provider_name = (_query_first(query, "provider") or "").strip() - flow_id = (_query_first(query, "flow_id") or "").strip() - spec = find_by_name(provider_name) - if spec is None or spec.name not in {"openai_codex", "xai_grok"}: - raise WebUISettingsError("OAuth completion is not supported for this provider") - if not flow_id: - raise WebUISettingsError("flow_id is required") - - flow = oauth_flows.get(spec.name, flow_id) - if flow is None: - raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410) - - try: - if spec.name == "openai_codex": - from nanobot.providers.openai_codex_oauth import ( - OpenAICodexOAuthInputError, - complete_openai_codex_oauth_login, - ) - - try: - token = complete_openai_codex_oauth_login(flow, authorization_response) - except OpenAICodexOAuthInputError as e: - raise WebUISettingsError(str(e), status=400) from e - else: - from nanobot.providers.xai_oauth import complete_xai_oauth_login - - token = complete_xai_oauth_login(flow, authorization_response) - except WebUISettingsError: - raise - except Exception as e: - oauth_flows.remove(spec.name, flow_id, flow) - raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e - if token is None: - return { - "status": "pending", - "provider": spec.name, - "flow_id": flow_id, - } - oauth_flows.remove(spec.name, flow_id, flow, cancel=False) - if not token.access: - raise WebUISettingsError("OAuth login failed", status=401) - return settings_payload(config_path=config_path) + return models.complete_oauth_provider( + query, + authorization_response, + oauth_flows=oauth_flows, + config_path=config_path, + settings_payload=settings_payload, + ) def logout_oauth_provider( @@ -1990,40 +355,12 @@ def logout_oauth_provider( oauth_flows: WebUIOAuthFlowRegistry, config_path: Path | None = None, ) -> dict[str, Any]: - provider_name = (_query_first(query, "provider") or "").strip() - if not provider_name: - raise WebUISettingsError("provider is required") - spec = find_by_name(provider_name) - if spec is None or not spec.is_oauth: - raise WebUISettingsError("unknown OAuth provider") - - if spec.name == "openai_codex": - try: - from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER - from oauth_cli_kit.storage import FileTokenStorage - except ImportError: - raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None - oauth_flows.clear(spec.name) - token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path() - elif spec.name == "github_copilot": - try: - from nanobot.providers.github_copilot_provider import get_storage - except ImportError: - raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None - token_path = get_storage().get_token_path() - elif spec.name == "xai_grok": - from nanobot.providers.xai_oauth import logout_xai_oauth - - oauth_flows.clear(spec.name) - logout_xai_oauth() - return settings_payload(config_path=config_path) - else: - raise WebUISettingsError("OAuth logout is not supported for this provider") - - for path in (token_path, token_path.with_suffix(".lock")): - with suppress(FileNotFoundError): - path.unlink() - return settings_payload(config_path=config_path) + return models.logout_oauth_provider( + query, + oauth_flows=oauth_flows, + config_path=config_path, + settings_payload=settings_payload, + ) def update_network_safety_settings( @@ -2031,30 +368,14 @@ def update_network_safety_settings( *, config_path: Path | None = None, ) -> dict[str, Any]: - raw_allow = ( - _query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess") - or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess") - ) - raw_default_access_mode = _query_first_alias(query, "webui_default_access_mode", "webuiDefaultAccessMode") - if raw_allow is None and raw_default_access_mode is None: - raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required") - config = _load_settings_config(config_path) - changed = False - if raw_allow is not None: - webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access") - if config.tools.webui_allow_local_service_access != webui_allow_local_service_access: - config.tools.webui_allow_local_service_access = webui_allow_local_service_access - changed = True - + changed, default_access_mode = capabilities.update_network_safety_settings( + config, + query, + ) if changed: _save_settings_config(config, config_path) - if raw_default_access_mode is not None: - default_access_mode = raw_default_access_mode.strip().lower() - if default_access_mode == "restricted": - default_access_mode = "default" - if default_access_mode not in {"default", "full"}: - raise WebUISettingsError("webui_default_access_mode must be default or full") + if default_access_mode is not None: try: write_webui_default_access_mode(default_access_mode) except ValueError as exc: @@ -2067,92 +388,14 @@ def update_web_search_settings( *, config_path: Path | None = None, ) -> dict[str, Any]: - provider_name = (_query_first(query, "provider") or "").strip().lower() - provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) - if provider_option is None: - raise WebUISettingsError("unknown web search provider") - config = _load_settings_config(config_path) - search_config = config.tools.web.search - web_config = config.tools.web - previous_provider = search_config.provider - changed = False - restart_required = False - - def set_search_value(attr: str, value: object) -> None: - nonlocal changed - if getattr(search_config, attr) != value: - setattr(search_config, attr, value) - changed = True - - def set_fetch_value(attr: str, value: object) -> None: - nonlocal changed - if getattr(web_config.fetch, attr) != value: - setattr(web_config.fetch, attr, value) - changed = True - - if search_config.provider != provider_name: - search_config.provider = provider_name - changed = True - - credential = provider_option["credential"] - if credential == "none": - set_search_value("api_key", "") - set_search_value("base_url", "") - elif credential == "base_url": - base_url = _query_first_alias(query, "base_url", "baseUrl") - base_url = base_url.strip() if base_url is not None else None - if not base_url and previous_provider == provider_name and search_config.base_url: - base_url = search_config.base_url - if not base_url: - raise WebUISettingsError("base_url is required") - set_search_value("base_url", base_url) - set_search_value("api_key", "") - elif credential in {"api_key", "optional_api_key"}: - raw_api_key = _query_first_alias(query, "api_key", "apiKey") - api_key = raw_api_key.strip() if raw_api_key is not None else None - if api_key is None and previous_provider == provider_name and search_config.api_key: - api_key = search_config.api_key - if credential == "api_key" and not api_key: - raise WebUISettingsError("api_key is required") - set_search_value("api_key", api_key or "") - set_search_value("base_url", "") - else: - raise WebUISettingsError("unknown web search credential type") - - max_results = _query_first_alias(query, "max_results", "maxResults") - if max_results is not None: - try: - parsed = int(max_results) - except ValueError: - raise WebUISettingsError("max_results must be an integer") from None - if parsed < 1 or parsed > 10: - raise WebUISettingsError("max_results must be between 1 and 10") - set_search_value("max_results", parsed) - - timeout = _query_first(query, "timeout") - if timeout is not None: - try: - parsed_timeout = int(timeout) - except ValueError: - raise WebUISettingsError("timeout must be an integer") from None - if parsed_timeout < 1 or parsed_timeout > 120: - raise WebUISettingsError("timeout must be between 1 and 120") - set_search_value("timeout", parsed_timeout) - - use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader") - if use_jina_reader is not None: - normalized = use_jina_reader.strip().lower() - if normalized not in {"1", "0", "true", "false", "yes", "no"}: - raise WebUISettingsError("use_jina_reader must be boolean") - previous_jina_reader = web_config.fetch.use_jina_reader - set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"}) - if web_config.fetch.use_jina_reader != previous_jina_reader: - restart_required = True - + changed, restart_required = capabilities.update_web_search_settings(config, query) if changed: _save_settings_config(config, config_path) - return settings_payload(requires_restart=restart_required, config_path=config_path) + return settings_payload( + requires_restart=restart_required, + config_path=config_path, + ) def update_api_settings( @@ -2160,44 +403,8 @@ def update_api_settings( *, config_path: Path | None = None, ) -> dict[str, Any]: - """Update the managed OpenAI-compatible API configuration.""" config = _load_settings_config(config_path) - api = config.api - - host = _query_first(query, "host") - if host is not None: - host = host.strip() - if not host: - raise WebUISettingsError("host is required") - api.host = host - - port = _query_first(query, "port") - if port is not None: - try: - parsed_port = int(port) - except ValueError: - raise WebUISettingsError("port must be an integer") from None - if parsed_port < 1 or parsed_port > 65535: - raise WebUISettingsError("port must be between 1 and 65535") - api.port = parsed_port - - timeout = _query_first(query, "timeout") - if timeout is not None: - try: - parsed_timeout = float(timeout) - except ValueError: - raise WebUISettingsError("timeout must be a number") from None - if parsed_timeout < 1 or parsed_timeout > 3600: - raise WebUISettingsError("timeout must be between 1 and 3600") - api.timeout = parsed_timeout - - api_key = _query_first_alias(query, "api_key", "apiKey") - if api_key is not None: - api.api_key = api_key.strip() - - if not is_loopback_host(api.host) and not api.api_key.strip(): - raise WebUISettingsError("an API key is required when the API is available on the network") - + capabilities.update_api_settings(config, query) _save_settings_config(config, config_path) return settings_payload(config_path=config_path) @@ -2208,97 +415,11 @@ def update_image_generation_settings( config_path: Path | None = None, ) -> dict[str, Any]: config = _load_settings_config(config_path) - image_config = config.tools.image_generation - changed = False - - provider_name = _query_first(query, "provider") - if provider_name is not None: - provider_name = provider_name.strip().lower() - if not provider_name: - raise WebUISettingsError("image generation provider is required") - if get_image_gen_provider(provider_name) is None: - raise WebUISettingsError("unknown image generation provider") - if image_config.provider != provider_name: - image_config.provider = provider_name - changed = True - - enabled = _query_first(query, "enabled") - if enabled is not None: - parsed_enabled = _parse_bool(enabled, "enabled") - if image_config.enabled != parsed_enabled: - image_config.enabled = parsed_enabled - changed = True - - model = _query_first(query, "model") - if model is not None: - model = model.strip() - if not model: - raise WebUISettingsError("image generation model is required") - if len(model) > 200: - raise WebUISettingsError("image generation model is too long") - if image_config.model != model: - image_config.model = model - changed = True - - default_aspect_ratio = _query_first_alias( + changed = capabilities.update_image_generation_settings( + config, query, - "default_aspect_ratio", - "defaultAspectRatio", + oauth_status=_oauth_provider_status, ) - if default_aspect_ratio is not None: - default_aspect_ratio = default_aspect_ratio.strip() - if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS: - raise WebUISettingsError("unsupported image generation aspect ratio") - if image_config.default_aspect_ratio != default_aspect_ratio: - image_config.default_aspect_ratio = default_aspect_ratio - changed = True - - default_image_size = _query_first_alias( - query, - "default_image_size", - "defaultImageSize", - ) - if default_image_size is not None: - default_image_size = default_image_size.strip() - if not default_image_size: - raise WebUISettingsError("default image size is required") - if len(default_image_size) > 32 or not all( - char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"}) - for char in default_image_size - ): - raise WebUISettingsError("unsupported image generation size") - if image_config.default_image_size != default_image_size: - image_config.default_image_size = default_image_size - changed = True - - max_images_per_turn = _query_first_alias( - query, - "max_images_per_turn", - "maxImagesPerTurn", - ) - if max_images_per_turn is not None: - try: - parsed_max = int(max_images_per_turn) - except ValueError: - raise WebUISettingsError("max_images_per_turn must be an integer") from None - if parsed_max < 1 or parsed_max > 8: - raise WebUISettingsError("max_images_per_turn must be between 1 and 8") - if image_config.max_images_per_turn != parsed_max: - image_config.max_images_per_turn = parsed_max - changed = True - - if image_config.enabled: - selected_provider = next( - ( - provider - for provider in _image_generation_provider_rows(config) - if provider["name"] == image_config.provider - ), - None, - ) - if not selected_provider or not selected_provider["configured"]: - raise WebUISettingsError("image generation provider is not configured") - if changed: _save_settings_config(config, config_path) return settings_payload(requires_restart=changed, config_path=config_path) @@ -2310,69 +431,6 @@ def update_transcription_settings( config_path: Path | None = None, ) -> dict[str, Any]: config = _load_settings_config(config_path) - transcription = config.transcription - changed = False - - enabled = _query_first(query, "enabled") - if enabled is not None: - parsed_enabled = _parse_bool(enabled, "enabled") - if transcription.enabled != parsed_enabled: - transcription.enabled = parsed_enabled - changed = True - - provider = _query_first(query, "provider") - if provider is not None: - provider = provider.strip().lower() - provider_spec = resolve_transcription_provider(provider) - if provider_spec is None: - raise WebUISettingsError("unknown transcription provider") - provider = provider_spec.name - if transcription.provider != provider: - transcription.provider = provider - changed = True - - model = _query_first(query, "model") - if model is not None: - model = model.strip() or None - if model is not None and len(model) > 200: - raise WebUISettingsError("transcription model is too long") - if transcription.model != model: - transcription.model = model - changed = True - - language = _query_first(query, "language") - if language is not None: - language = language.strip().lower() or None - if language is not None and not re.fullmatch(r"[a-z]{2,3}", language): - raise WebUISettingsError("transcription language must be 2-3 lowercase letters") - if transcription.language != language: - transcription.language = language - changed = True - - max_duration_sec = _query_first_alias(query, "max_duration_sec", "maxDurationSec") - if max_duration_sec is not None: - try: - parsed_duration = int(max_duration_sec) - except ValueError: - raise WebUISettingsError("max_duration_sec must be an integer") from None - if parsed_duration < 1 or parsed_duration > 600: - raise WebUISettingsError("max_duration_sec must be between 1 and 600") - if transcription.max_duration_sec != parsed_duration: - transcription.max_duration_sec = parsed_duration - changed = True - - max_upload_mb = _query_first_alias(query, "max_upload_mb", "maxUploadMb") - if max_upload_mb is not None: - try: - parsed_upload = int(max_upload_mb) - except ValueError: - raise WebUISettingsError("max_upload_mb must be an integer") from None - if parsed_upload < 1 or parsed_upload > 100: - raise WebUISettingsError("max_upload_mb must be between 1 and 100") - if transcription.max_upload_mb != parsed_upload: - transcription.max_upload_mb = parsed_upload - changed = True - - if changed: + if capabilities.update_transcription_settings(config, query): _save_settings_config(config, config_path) return settings_payload(config_path=config_path) diff --git a/nanobot/webui/settings_capabilities.py b/nanobot/webui/settings_capabilities.py new file mode 100644 index 000000000..c4bc5cdd7 --- /dev/null +++ b/nanobot/webui/settings_capabilities.py @@ -0,0 +1,804 @@ +"""Capability settings domain logic for Web, media, network, and API features.""" + +from __future__ import annotations + +import asyncio +import os +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict + +from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS +from nanobot.api.runtime import ApiRuntime, ApiStartOptions +from nanobot.audio.transcription import resolve_transcription_config +from nanobot.audio.transcription_registry import ( + resolve_transcription_provider, + transcription_provider_names, +) +from nanobot.config.schema import Config +from nanobot.optional_features import ( + OptionalFeatureError, + extra_installed, + optional_dependency_groups, +) +from nanobot.providers.image_generation import ( + get_image_gen_provider, + image_gen_provider_names, +) +from nanobot.providers.registry import find_by_name +from nanobot.security.network import is_loopback_host +from nanobot.webui.settings_contracts import ( + QueryParams, + SettingsRequest, + SettingsRouteResult, + WebUISettingsError, + parse_bool, + query_first, + query_first_alias, +) +from nanobot.webui.settings_models import ( + OAuthStatusReader, + mask_secret_hint, + provider_configured_for_settings, +) +from nanobot.webui.workspaces import ( + read_webui_default_access_mode, +) + +if TYPE_CHECKING: + from nanobot.webui.settings_services import WebUISettingsServices + +SettingsOperation = Callable[..., dict[str, Any]] + + +@dataclass(frozen=True) +class CapabilitySettingsOperations: + update_web_search: SettingsOperation + update_api: SettingsOperation + update_image: SettingsOperation + update_transcription: SettingsOperation + update_network: SettingsOperation + nanobot_features_action: SettingsOperation + api_runtime: Callable[[], ApiRuntime] + reload_image: Callable[[], Awaitable[dict[str, Any]]] + + +class CapabilitySettingsPayload(TypedDict): + web_search: dict[str, Any] + web: dict[str, Any] + api: dict[str, Any] + observability: dict[str, Any] + image_generation: dict[str, Any] + transcription: dict[str, Any] + + +_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS +_WEB_SEARCH_PROVIDER_BY_NAME = { + provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS +} +_IMAGE_GENERATION_ASPECT_RATIOS = { + "1:1", + "3:4", + "9:16", + "4:3", + "16:9", + "3:2", + "2:3", + "21:9", +} + + +def _image_generation_provider_rows( + config: Config, + *, + oauth_status: OAuthStatusReader, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for name in image_gen_provider_names(): + image_provider = get_image_gen_provider(name) + spec = find_by_name(name) + provider_config = getattr(config.providers, name, None) + configured = ( + provider_configured_for_settings(spec, provider_config, oauth_status) + if spec is not None and provider_config is not None + else bool(getattr(provider_config, "api_key", None)) + ) + rows.append( + { + "name": name, + "label": spec.label if spec is not None else name, + "configured": configured, + "auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key", + "api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)), + "api_base": getattr(provider_config, "api_base", None), + "default_api_base": ( + spec.default_api_base if spec and spec.default_api_base else None + ), + "models": list(image_provider.model_options) if image_provider else [], + "default_model": ( + image_provider.model_options[0] + if image_provider and image_provider.model_options + else None + ), + } + ) + return rows + + +def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for name in transcription_provider_names(): + spec = find_by_name(name) + provider_config = getattr(config.providers, name, None) + rows.append( + { + "name": name, + "label": spec.label if spec is not None else name, + "configured": bool(getattr(provider_config, "api_key", None)), + "api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)), + "api_base": getattr(provider_config, "api_base", None), + "default_api_base": ( + spec.default_api_base if spec and spec.default_api_base else None + ), + } + ) + return rows + + +def capability_settings_payload( + config: Config, + *, + oauth_status: OAuthStatusReader, +) -> CapabilitySettingsPayload: + search_config = config.tools.web.search + image_config = config.tools.image_generation + transcription = resolve_transcription_config(config) + search_provider = ( + search_config.provider + if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME + else "duckduckgo" + ) + image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status) + selected_image_provider = next( + ( + provider + for provider in image_providers + if provider["name"] == image_config.provider + ), + None, + ) + return { + "web_search": { + "provider": search_provider, + "api_key_hint": mask_secret_hint(search_config.api_key), + "base_url": search_config.base_url or None, + "max_results": search_config.max_results, + "timeout": search_config.timeout, + "providers": list(_WEB_SEARCH_PROVIDER_OPTIONS), + }, + "web": { + "enable": config.tools.web.enable, + "proxy": config.tools.web.proxy, + "user_agent": config.tools.web.user_agent, + "search": { + "max_results": search_config.max_results, + "timeout": search_config.timeout, + }, + "fetch": { + "use_jina_reader": config.tools.web.fetch.use_jina_reader, + }, + }, + "api": { + "host": config.api.host, + "port": config.api.port, + "timeout": config.api.timeout, + "api_key_hint": mask_secret_hint(config.api.api_key), + }, + "observability": { + "provider": "langfuse", + "configured": bool( + os.environ.get("LANGFUSE_SECRET_KEY") + and os.environ.get("LANGFUSE_PUBLIC_KEY") + ), + "base_url": os.environ.get("LANGFUSE_BASE_URL") + or "https://cloud.langfuse.com", + }, + "image_generation": { + "enabled": image_config.enabled, + "provider": image_config.provider, + "provider_configured": bool( + selected_image_provider and selected_image_provider["configured"] + ), + "model": image_config.model, + "default_aspect_ratio": image_config.default_aspect_ratio, + "default_image_size": image_config.default_image_size, + "max_images_per_turn": image_config.max_images_per_turn, + "save_dir": image_config.save_dir, + "providers": image_providers, + }, + "transcription": { + "enabled": transcription.enabled, + "provider": transcription.provider, + "provider_configured": transcription.configured, + "model": transcription.model, + "language": transcription.language, + "max_duration_sec": transcription.max_duration_sec, + "max_upload_mb": transcription.max_upload_mb, + "providers": _transcription_provider_rows(config), + }, + } + + +def update_network_safety_settings( + config: Config, + query: QueryParams, +) -> tuple[bool, str | None]: + raw_allow = ( + query_first_alias( + query, + "webui_allow_local_service_access", + "webuiAllowLocalServiceAccess", + ) + or query_first_alias( + query, + "allow_local_preview_access", + "allowLocalPreviewAccess", + ) + ) + raw_default_access_mode = query_first_alias( + query, + "webui_default_access_mode", + "webuiDefaultAccessMode", + ) + if raw_allow is None and raw_default_access_mode is None: + raise WebUISettingsError( + "webui_allow_local_service_access or webui_default_access_mode is required" + ) + + changed = False + if raw_allow is not None: + allow_local = parse_bool(raw_allow, "webui_allow_local_service_access") + if config.tools.webui_allow_local_service_access != allow_local: + config.tools.webui_allow_local_service_access = allow_local + changed = True + + default_access_mode: str | None = None + if raw_default_access_mode is not None: + default_access_mode = raw_default_access_mode.strip().lower() + if default_access_mode == "restricted": + default_access_mode = "default" + if default_access_mode not in {"default", "full"}: + raise WebUISettingsError( + "webui_default_access_mode must be default or full" + ) + return changed, default_access_mode + + +def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]: + provider_name = (query_first(query, "provider") or "").strip().lower() + provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) + if provider_option is None: + raise WebUISettingsError("unknown web search provider") + + search_config = config.tools.web.search + web_config = config.tools.web + previous_provider = search_config.provider + changed = False + restart_required = False + + def set_search_value(attr: str, value: object) -> None: + nonlocal changed + if getattr(search_config, attr) != value: + setattr(search_config, attr, value) + changed = True + + def set_fetch_value(attr: str, value: object) -> None: + nonlocal changed + if getattr(web_config.fetch, attr) != value: + setattr(web_config.fetch, attr, value) + changed = True + + if search_config.provider != provider_name: + search_config.provider = provider_name + changed = True + + credential = provider_option["credential"] + if credential == "none": + set_search_value("api_key", "") + set_search_value("base_url", "") + elif credential == "base_url": + base_url = query_first_alias(query, "base_url", "baseUrl") + base_url = base_url.strip() if base_url is not None else None + if not base_url and previous_provider == provider_name and search_config.base_url: + base_url = search_config.base_url + if not base_url: + raise WebUISettingsError("base_url is required") + set_search_value("base_url", base_url) + set_search_value("api_key", "") + elif credential in {"api_key", "optional_api_key"}: + raw_api_key = query_first_alias(query, "api_key", "apiKey") + api_key = raw_api_key.strip() if raw_api_key is not None else None + if api_key is None and previous_provider == provider_name and search_config.api_key: + api_key = search_config.api_key + if credential == "api_key" and not api_key: + raise WebUISettingsError("api_key is required") + set_search_value("api_key", api_key or "") + set_search_value("base_url", "") + else: + raise WebUISettingsError("unknown web search credential type") + + max_results = query_first_alias(query, "max_results", "maxResults") + if max_results is not None: + try: + parsed = int(max_results) + except ValueError: + raise WebUISettingsError("max_results must be an integer") from None + if parsed < 1 or parsed > 10: + raise WebUISettingsError("max_results must be between 1 and 10") + set_search_value("max_results", parsed) + + timeout = query_first(query, "timeout") + if timeout is not None: + try: + parsed_timeout = int(timeout) + except ValueError: + raise WebUISettingsError("timeout must be an integer") from None + if parsed_timeout < 1 or parsed_timeout > 120: + raise WebUISettingsError("timeout must be between 1 and 120") + set_search_value("timeout", parsed_timeout) + + use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader") + if use_jina_reader is not None: + previous_jina_reader = web_config.fetch.use_jina_reader + set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader")) + if web_config.fetch.use_jina_reader != previous_jina_reader: + restart_required = True + return changed, restart_required + + +def update_api_settings(config: Config, query: QueryParams) -> None: + """Update the managed OpenAI-compatible API configuration.""" + api = config.api + host = query_first(query, "host") + if host is not None: + host = host.strip() + if not host: + raise WebUISettingsError("host is required") + api.host = host + + port = query_first(query, "port") + if port is not None: + try: + parsed_port = int(port) + except ValueError: + raise WebUISettingsError("port must be an integer") from None + if parsed_port < 1 or parsed_port > 65535: + raise WebUISettingsError("port must be between 1 and 65535") + api.port = parsed_port + + timeout = query_first(query, "timeout") + if timeout is not None: + try: + parsed_timeout = float(timeout) + except ValueError: + raise WebUISettingsError("timeout must be a number") from None + if parsed_timeout < 1 or parsed_timeout > 3600: + raise WebUISettingsError("timeout must be between 1 and 3600") + api.timeout = parsed_timeout + + api_key = query_first_alias(query, "api_key", "apiKey") + if api_key is not None: + api.api_key = api_key.strip() + if not is_loopback_host(api.host) and not api.api_key.strip(): + raise WebUISettingsError( + "an API key is required when the API is available on the network" + ) + + +def update_image_generation_settings( + config: Config, + query: QueryParams, + *, + oauth_status: OAuthStatusReader, +) -> bool: + image_config = config.tools.image_generation + changed = False + + provider_name = query_first(query, "provider") + if provider_name is not None: + provider_name = provider_name.strip().lower() + if not provider_name: + raise WebUISettingsError("image generation provider is required") + if get_image_gen_provider(provider_name) is None: + raise WebUISettingsError("unknown image generation provider") + if image_config.provider != provider_name: + image_config.provider = provider_name + changed = True + + enabled = query_first(query, "enabled") + if enabled is not None: + parsed_enabled = parse_bool(enabled, "enabled") + if image_config.enabled != parsed_enabled: + image_config.enabled = parsed_enabled + changed = True + + model = query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + raise WebUISettingsError("image generation model is required") + if len(model) > 200: + raise WebUISettingsError("image generation model is too long") + if image_config.model != model: + image_config.model = model + changed = True + + default_aspect_ratio = query_first_alias( + query, + "default_aspect_ratio", + "defaultAspectRatio", + ) + if default_aspect_ratio is not None: + default_aspect_ratio = default_aspect_ratio.strip() + if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS: + raise WebUISettingsError("unsupported image generation aspect ratio") + if image_config.default_aspect_ratio != default_aspect_ratio: + image_config.default_aspect_ratio = default_aspect_ratio + changed = True + + default_image_size = query_first_alias( + query, + "default_image_size", + "defaultImageSize", + ) + if default_image_size is not None: + default_image_size = default_image_size.strip() + if not default_image_size: + raise WebUISettingsError("default image size is required") + if len(default_image_size) > 32 or not all( + char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"}) + for char in default_image_size + ): + raise WebUISettingsError("unsupported image generation size") + if image_config.default_image_size != default_image_size: + image_config.default_image_size = default_image_size + changed = True + + max_images_per_turn = query_first_alias( + query, + "max_images_per_turn", + "maxImagesPerTurn", + ) + if max_images_per_turn is not None: + try: + parsed_max = int(max_images_per_turn) + except ValueError: + raise WebUISettingsError("max_images_per_turn must be an integer") from None + if parsed_max < 1 or parsed_max > 8: + raise WebUISettingsError("max_images_per_turn must be between 1 and 8") + if image_config.max_images_per_turn != parsed_max: + image_config.max_images_per_turn = parsed_max + changed = True + + if image_config.enabled: + selected_provider = next( + ( + provider + for provider in _image_generation_provider_rows( + config, + oauth_status=oauth_status, + ) + if provider["name"] == image_config.provider + ), + None, + ) + if not selected_provider or not selected_provider["configured"]: + raise WebUISettingsError("image generation provider is not configured") + return changed + + +def update_transcription_settings(config: Config, query: QueryParams) -> bool: + transcription = config.transcription + changed = False + + enabled = query_first(query, "enabled") + if enabled is not None: + parsed_enabled = parse_bool(enabled, "enabled") + if transcription.enabled != parsed_enabled: + transcription.enabled = parsed_enabled + changed = True + + provider = query_first(query, "provider") + if provider is not None: + provider = provider.strip().lower() + provider_spec = resolve_transcription_provider(provider) + if provider_spec is None: + raise WebUISettingsError("unknown transcription provider") + provider = provider_spec.name + if transcription.provider != provider: + transcription.provider = provider + changed = True + + model = query_first(query, "model") + if model is not None: + model = model.strip() or None + if model is not None and len(model) > 200: + raise WebUISettingsError("transcription model is too long") + if transcription.model != model: + transcription.model = model + changed = True + + language = query_first(query, "language") + if language is not None: + language = language.strip().lower() or None + if language is not None and not re.fullmatch(r"[a-z]{2,3}", language): + raise WebUISettingsError( + "transcription language must be 2-3 lowercase letters" + ) + if transcription.language != language: + transcription.language = language + changed = True + + max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec") + if max_duration_sec is not None: + try: + parsed_duration = int(max_duration_sec) + except ValueError: + raise WebUISettingsError("max_duration_sec must be an integer") from None + if parsed_duration < 1 or parsed_duration > 600: + raise WebUISettingsError("max_duration_sec must be between 1 and 600") + if transcription.max_duration_sec != parsed_duration: + transcription.max_duration_sec = parsed_duration + changed = True + + max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb") + if max_upload_mb is not None: + try: + parsed_upload = int(max_upload_mb) + except ValueError: + raise WebUISettingsError("max_upload_mb must be an integer") from None + if parsed_upload < 1 or parsed_upload > 100: + raise WebUISettingsError("max_upload_mb must be between 1 and 100") + if transcription.max_upload_mb != parsed_upload: + transcription.max_upload_mb = parsed_upload + changed = True + return changed + + +def network_safety_payload(config: Config) -> dict[str, Any]: + """Return the network-related fields embedded in the advanced DTO.""" + return { + "webui_allow_local_service_access": config.tools.webui_allow_local_service_access, + "allow_local_preview_access": config.tools.webui_allow_local_service_access, + "webui_default_access_mode": read_webui_default_access_mode(), + "private_service_protection_enabled": True, + "ssrf_whitelist_count": len(config.tools.ssrf_whitelist), + } + + +def masked_api_secret(value: str) -> str | None: + value = value.strip() + if not value: + return None + return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured" + + +def api_runtime_message(message: str) -> str: + known = { + "api_exited_during_startup": "API server exited during startup. Check its log for details.", + "api_stop_timeout": "API server did not stop in time.", + "api_state_stale": "API server state was stale; try starting it again.", + } + if message in known: + return known[message] + if message.startswith("api_"): + return f"API server {message.removeprefix('api_').replace('_', ' ')}" + return message.replace("_", " ") + + +def api_service_payload( + settings: WebUISettingsServices, + runtime: ApiRuntime, + *, + last_action: str | None = None, +) -> dict[str, Any]: + config = settings.config.load() + status = runtime.status() + extras = optional_dependency_groups() + connect_host = ( + "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host + ) + payload = { + "installed": extra_installed("api", extras.get("api")), + "running": status.running, + "managed": status.running, + "host": config.api.host, + "port": config.api.port, + "timeout": config.api.timeout, + "api_key_hint": masked_api_secret(config.api.api_key), + "endpoint": f"http://{connect_host}:{config.api.port}/v1", + "command": "nanobot serve", + "log_path": str(status.log_path), + } + if last_action: + payload["last_action"] = last_action + return payload + + +class CapabilitySettingsHandler: + """Handle capability commands after transport authentication and decoding.""" + + def __init__(self, settings: WebUISettingsServices, logger: Any) -> None: + self.settings = settings + self.logger = logger + + async def handle( + self, + action: str, + request: SettingsRequest, + operations: CapabilitySettingsOperations, + ) -> SettingsRouteResult: + if action == "api-status": + return SettingsRouteResult.success( + api_service_payload(self.settings, operations.api_runtime()) + ) + if action == "api-start": + return await self._start_api(request, operations) + if action == "api-stop": + return await self._stop_api(operations) + + mutation = { + "web-search-update": ( + operations.update_web_search, + "browser", + False, + ), + "transcription-update": ( + operations.update_transcription, + None, + False, + ), + "network-update": ( + operations.update_network, + "runtime", + False, + ), + "image-update": ( + operations.update_image, + "image", + True, + ), + }.get(action) + if mutation is None: + return SettingsRouteResult.failure(404, "unknown settings action") + + operation, section, apply_image_reload = mutation + try: + payload = self.settings.mutate(operation, request.query) + except WebUISettingsError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + if apply_image_reload: + payload, image_restart_cleared = await self.apply_image_runtime_change( + payload, + operations.reload_image, + ) + else: + image_restart_cleared = False + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section=section, + clear_restart_section=("image" if image_restart_cleared else None), + ) + + async def apply_image_runtime_change( + self, + payload: dict[str, Any], + reload_image: Callable[[], Awaitable[dict[str, Any]]], + ) -> tuple[dict[str, Any], bool]: + """Hot-apply image settings, preserving restart fallback on failure.""" + if not payload.get("requires_restart"): + return payload, False + try: + result = await reload_image() + except Exception: + self.logger.exception("failed to hot-reload image generation settings") + return payload, False + + applied = bool(result.get("ok")) and not result.get("requires_restart") + updated = dict(payload) + updated["requires_restart"] = not applied + if not applied: + self.logger.warning( + "image generation settings were saved but require restart: {}", + result.get("message") or "hot reload failed", + ) + return updated, applied + + async def _start_api( + self, + request: SettingsRequest, + operations: CapabilitySettingsOperations, + ) -> SettingsRouteResult: + api_key = (request.payload or {}).get("api_key") + if api_key is not None and not isinstance(api_key, str): + return SettingsRouteResult.failure( + 400, + "API service API key must be a string", + ) + try: + await asyncio.to_thread( + self.settings.mutate, + operations.nanobot_features_action, + "enable", + {"name": ["api"]}, + allow_install=self._allow_feature_package_install(request), + ) + self.settings.mutate(operations.update_api, request.query) + config = self.settings.config.load() + runtime = operations.api_runtime() + options = ApiStartOptions( + host=config.api.host, + port=config.api.port, + workspace=str(config.workspace_path), + config_path=str(self.settings.config.path), + ) + current = runtime.status() + result = await asyncio.to_thread( + runtime.restart if current.running else runtime.start_background, + options, + ) + if not result.ok: + return SettingsRouteResult.failure( + 500, + api_runtime_message(result.message), + ) + except (WebUISettingsError, OptionalFeatureError) as exc: + return SettingsRouteResult.failure( + getattr(exc, "status", 400), + getattr(exc, "message", str(exc)), + ) + except Exception as exc: + self.logger.exception("failed to start managed API service") + return SettingsRouteResult.failure(500, str(exc)) + return SettingsRouteResult.success( + api_service_payload( + self.settings, + operations.api_runtime(), + last_action="started", + ) + ) + + async def _stop_api( + self, + operations: CapabilitySettingsOperations, + ) -> SettingsRouteResult: + runtime = operations.api_runtime() + try: + result = await asyncio.to_thread(runtime.stop) + except Exception as exc: + self.logger.exception("failed to stop managed API service") + return SettingsRouteResult.failure(500, str(exc)) + if not result.ok and result.message != "api_not_running": + return SettingsRouteResult.failure( + 500, + api_runtime_message(result.message), + ) + return SettingsRouteResult.success( + api_service_payload( + self.settings, + operations.api_runtime(), + last_action="stopped", + ) + ) + + def _allow_feature_package_install(self, request: SettingsRequest) -> bool: + if request.local_browser: + return True + try: + return bool( + self.settings.config.load().tools.webui_allow_remote_package_install + ) + except Exception: + self.logger.exception("failed to load remote package install policy") + return False diff --git a/nanobot/webui/settings_contracts.py b/nanobot/webui/settings_contracts.py new file mode 100644 index 000000000..df99507c5 --- /dev/null +++ b/nanobot/webui/settings_contracts.py @@ -0,0 +1,82 @@ +"""Stable request and error contracts shared by WebUI settings domains.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +QueryParams = dict[str, list[str]] + + +@dataclass(frozen=True) +class SettingsRequest: + """Transport-neutral input decoded by the settings route facade.""" + + query: QueryParams + payload: dict[str, Any] | None = None + local_browser: bool = False + + +@dataclass(frozen=True) +class SettingsRouteResult: + """Transport-neutral result returned by a settings domain handler.""" + + payload: dict[str, Any] | None = None + status: int = 200 + error: str | None = None + decorate_restart: bool = False + restart_section: str | None = None + clear_restart_section: str | None = None + restart_payload_key: str | None = None + + @classmethod + def success( + cls, + payload: dict[str, Any], + *, + decorate_restart: bool = False, + restart_section: str | None = None, + clear_restart_section: str | None = None, + restart_payload_key: str | None = None, + ) -> SettingsRouteResult: + return cls( + payload=payload, + decorate_restart=decorate_restart, + restart_section=restart_section, + clear_restart_section=clear_restart_section, + restart_payload_key=restart_payload_key, + ) + + @classmethod + def failure(cls, status: int, error: str) -> SettingsRouteResult: + return cls(status=status, error=error) + + +class WebUISettingsError(ValueError): + """User-facing settings validation failure.""" + + def __init__(self, message: str, *, status: int = 400) -> None: + super().__init__(message) + self.message = message + self.status = status + + +def query_first(query: QueryParams, key: str) -> str | None: + values = query.get(key) + return values[0] if values else None + + +def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None: + value = query_first(query, snake) + return query_first(query, camel) if value is None else value + + +def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool: + return snake in query or camel in query + + +def parse_bool(value: str, field: str) -> bool: + normalized = value.strip().lower() + if normalized not in {"1", "0", "true", "false", "yes", "no"}: + raise WebUISettingsError(f"{field} must be boolean") + return normalized in {"1", "true", "yes"} diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py new file mode 100644 index 000000000..a3816b9d4 --- /dev/null +++ b/nanobot/webui/settings_models.py @@ -0,0 +1,1693 @@ +"""Model and provider settings domain logic. + +This module owns model/provider DTO construction, validation, configuration +updates, model discovery, and OAuth workflows. It deliberately has no +dependency on the WebSocket transport. +""" + +# oauth-cli-kit does not publish type stubs. +# pyright: reportMissingTypeStubs=false + +from __future__ import annotations + +import asyncio +import json +import math +import os +import re +import secrets +import time +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict, cast + +import httpx + +from nanobot.config.loader import resolve_config_env_vars +from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig +from nanobot.providers.image_generation import get_image_gen_provider +from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE +from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name +from nanobot.webui.settings_contracts import ( + QueryParams, + SettingsRequest, + SettingsRouteResult, + WebUISettingsError, + parse_bool, + query_first, + query_first_alias, + query_has_alias, +) + +if TYPE_CHECKING: + from nanobot.webui.settings_services import ( + WebUIOAuthFlowRegistry, + WebUISettingsServices, + ) + +OAuthStatusReader = Callable[[Any], dict[str, Any]] +SettingsPayloadBuilder = Callable[..., dict[str, Any]] +HttpGet = Callable[..., httpx.Response] +SettingsOperation = Callable[..., dict[str, Any]] + + +@dataclass(frozen=True) +class ModelSettingsOperations: + update_agent: SettingsOperation + create_model: SettingsOperation + update_model: SettingsOperation + delete_model: SettingsOperation + migrate_models: SettingsOperation + update_call_order: SettingsOperation + update_provider: SettingsOperation + create_provider: SettingsOperation + provider_models: SettingsOperation + oauth_login: SettingsOperation + oauth_complete: SettingsOperation + oauth_logout: SettingsOperation + apply_image_runtime_change: Callable[ + [dict[str, Any]], + Awaitable[tuple[dict[str, Any], bool]], + ] + + +class ModelSettingsPayload(TypedDict): + agent: dict[str, Any] + model_presets: list[dict[str, Any]] + model_call_order: list[str] + model_call_order_editable: bool + providers: list[dict[str, Any]] + + +_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576} +_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"} +_WEBUI_OAUTH_TIMEOUT_S = 600 +_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") +_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +_REDACTED_PROVIDER_SECRET = "••••••••" +_PROVIDER_STRUCTURED_FIELDS = ("extra_headers", "extra_body", "extra_query") +_PROVIDER_SECRET_KEYS = frozenset({ + "auth", + "authentication", + "authorization", + "bearer", + "cookie", + "credential", + "credentials", + "hmac", + "key", + "passphrase", + "passwd", + "proxyauthorization", + "setcookie", + "sig", + "signature", +}) +_PROVIDER_SECRET_KEY_SUFFIXES = ( + "accesskey", + "apikey", + "encryptionkey", + "password", + "privatekey", + "secret", + "secretkey", + "signingkey", + "subscriptionkey", + "token", +) + + +def _provider_json_setting( + query: QueryParams, + snake: str, + camel: str, +) -> dict[str, Any] | None: + raw = (query_first_alias(query, snake, camel) or "").strip() + if not raw: + return None + try: + value: object = json.loads(raw) + except json.JSONDecodeError as exc: + raise WebUISettingsError(f"{snake} must be a JSON object") from exc + if not isinstance(value, dict): + raise WebUISettingsError(f"{snake} must be a JSON object") + return cast(dict[str, Any], value) or None + + +def _provider_setting_key_is_secret(key: str) -> bool: + compact = re.sub(r"[^a-z0-9]", "", key.lower()) + return compact in _PROVIDER_SECRET_KEYS or compact.endswith(_PROVIDER_SECRET_KEY_SUFFIXES) + + +def _redact_provider_secret_values(value: Any, *, secret: bool = False) -> Any: + if secret and value not in (None, ""): + return _REDACTED_PROVIDER_SECRET + if isinstance(value, dict): + value_mapping = cast(dict[str, Any], value) + return { + key: _redact_provider_secret_values( + item, + secret=_provider_setting_key_is_secret(key), + ) + for key, item in value_mapping.items() + } + if isinstance(value, list): + return [_redact_provider_secret_values(item) for item in cast(list[Any], value)] + return value + + +def _restore_redacted_provider_secret_values( + submitted: Any, + current: Any, + *, + secret: bool = False, +) -> Any: + if secret and submitted == _REDACTED_PROVIDER_SECRET: + return current + if isinstance(submitted, dict): + submitted_mapping = cast(dict[str, Any], submitted) + current_mapping = cast(dict[str, Any], current) if isinstance(current, dict) else {} + return { + key: _restore_redacted_provider_secret_values( + item, + current_mapping.get(key), + secret=_provider_setting_key_is_secret(key), + ) + for key, item in submitted_mapping.items() + } + if isinstance(submitted, list): + submitted_items = cast(list[Any], submitted) + current_items = cast(list[Any], current) if isinstance(current, list) else [] + return [ + _restore_redacted_provider_secret_values( + item, + current_items[index] if index < len(current_items) else None, + ) + for index, item in enumerate(submitted_items) + ] + return submitted + + +def _provider_config_updates(query: QueryParams) -> dict[str, Any]: + updates: dict[str, Any] = {} + string_fields = ( + ("api_key", "apiKey"), + ("api_base", "apiBase"), + ("api_type", "apiType"), + ("proxy", "proxy"), + ("thinking_style", "thinkingStyle"), + ("region", "region"), + ("profile", "profile"), + ("display_name", "displayName"), + ) + for snake, camel in string_fields: + if query_has_alias(query, snake, camel): + value = (query_first_alias(query, snake, camel) or "").strip() + updates[snake] = value or ("auto" if snake == "api_type" else None) + + for snake, camel in ( + ("extra_headers", "extraHeaders"), + ("extra_body", "extraBody"), + ("extra_query", "extraQuery"), + ): + if query_has_alias(query, snake, camel): + updates[snake] = _provider_json_setting(query, snake, camel) + return updates + + +def _validated_provider_config( + provider_config: ProviderConfig | None, + updates: dict[str, Any], +) -> ProviderConfig: + config_type = type(provider_config) if provider_config is not None else ProviderConfig + values = provider_config.model_dump(mode="python") if provider_config is not None else {} + if provider_config is not None: + for field in _PROVIDER_STRUCTURED_FIELDS: + if field in updates: + updates[field] = _restore_redacted_provider_secret_values( + updates[field], + getattr(provider_config, field), + ) + values.update(updates) + try: + return config_type.model_validate(values) + except ValueError as exc: + errors_callback = getattr(exc, "errors", None) + errors: list[dict[str, Any]] = ( + cast(Any, errors_callback)() if callable(errors_callback) else [] + ) + if errors: + error = errors[0] + field = ".".join(str(part) for part in error.get("loc", ())) + message = str(error.get("msg", "invalid value")) + raise WebUISettingsError(f"{field}: {message}" if field else message) from exc + raise WebUISettingsError(str(exc)) from exc + + +def mask_secret_hint(secret: str | None) -> str | None: + if not secret: + return None + if len(secret) <= 8: + return "••••" + return f"{secret[:4]}••••{secret[-4:]}" + + +def _resolve_env_placeholders(value: str | None) -> str | None: + if not value: + return None + missing = False + + def replace(match: re.Match[str]) -> str: + nonlocal missing + env_value = os.environ.get(match.group(1)) + if env_value is None: + missing = True + return "" + return env_value + + resolved = _ENV_REF_RE.sub(replace, value).strip() + if missing and not resolved: + return None + return resolved or None + + +def provider_requires_api_key(spec: Any) -> bool: + if spec.name == "azure_openai": + return False + if spec.is_oauth: + return False + if spec.is_local or spec.is_direct: + return False + return True + + +def provider_requires_api_base(spec: Any) -> bool: + if spec.name == "azure_openai": + return True + return bool(spec.backend == "openai_compat" and spec.is_direct and not spec.default_api_base) + + +def oauth_provider_status(spec: Any) -> dict[str, Any]: + if not getattr(spec, "is_oauth", False): + return {"configured": False, "account": None, "expires_at": None, "login_supported": False} + + if spec.name == "openai_codex": + try: + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + except Exception: + return { + "configured": False, + "account": None, + "expires_at": None, + "login_supported": False, + } + token = None + with suppress(Exception): + token = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).load() + expires_at = getattr(token, "expires", None) if token else None + now_ms = int(time.time() * 1000) + return { + "configured": bool( + token + and token.access + and (getattr(token, "refresh", None) or (expires_at and expires_at > now_ms)) + ), + "account": getattr(token, "account_id", None) if token else None, + "expires_at": expires_at, + "login_supported": True, + } + + if spec.name == "github_copilot": + try: + from nanobot.providers.github_copilot_provider import get_github_copilot_login_status + except Exception: + return { + "configured": False, + "account": None, + "expires_at": None, + "login_supported": False, + } + token = None + with suppress(Exception): + token = get_github_copilot_login_status() + return { + "configured": bool(token and token.access and token.expires > int(time.time() * 1000)), + "account": getattr(token, "account_id", None) if token else None, + "expires_at": getattr(token, "expires", None) if token else None, + "login_supported": True, + } + + if spec.name == "xai_grok": + try: + from nanobot.providers.xai_oauth import get_xai_oauth_login_status + except Exception: + return { + "configured": False, + "account": None, + "expires_at": None, + "login_supported": False, + } + token = None + with suppress(Exception): + token = get_xai_oauth_login_status() + expires_at = getattr(token, "expires", None) if token else None + now_ms = int(time.time() * 1000) + return { + "configured": bool( + token + and token.access + and (getattr(token, "refresh", None) or (expires_at and expires_at > now_ms)) + ), + "account": getattr(token, "account_id", None) if token else None, + "expires_at": expires_at, + "login_supported": True, + } + + return {"configured": False, "account": None, "expires_at": None, "login_supported": False} + + +def provider_configured_for_settings( + spec: Any, + provider_config: Any, + oauth_status: OAuthStatusReader, +) -> bool: + if spec.is_oauth: + return bool(oauth_status(spec)["configured"]) + if provider_requires_api_base(spec): + return bool(provider_config.api_base) + if provider_requires_api_key(spec): + return bool(provider_config.api_key) + return bool( + provider_config.api_key + or provider_config.api_base + or getattr(provider_config, "region", None) + or getattr(provider_config, "profile", None) + ) + + +def _dynamic_provider_items(config: Config) -> list[tuple[str, ProviderConfig]]: + model_extra = config.providers.model_extra or {} + return [ + (name, provider_config) + for name, provider_config in model_extra.items() + if isinstance(provider_config, ProviderConfig) + ] + + +def resolve_settings_provider( + config: Config, + provider_name: str, +) -> tuple[Any, str, ProviderConfig] | None: + spec = find_by_name(provider_name) + if spec is not None: + provider_config = getattr(config.providers, spec.name, None) + if isinstance(provider_config, ProviderConfig): + return spec, spec.name, provider_config + return None + + normalized = provider_name.replace("-", "_") + for extra_name, provider_config in _dynamic_provider_items(config): + if provider_name == extra_name or normalized == extra_name.replace("-", "_"): + return ( + create_dynamic_spec( + extra_name, + display_name=provider_config.display_name or "", + thinking_style=provider_config.thinking_style or "", + ), + extra_name, + provider_config, + ) + return None + + +def _provider_advanced_field_names(name: str, spec: Any) -> list[str]: + fields: list[str] = [] + if spec.backend in {"openai_compat", "anthropic"}: + fields.append("extra_headers") + if spec.backend in {"openai_compat", "bedrock", "openai_codex", "xai_grok"}: + fields.append("extra_body") + if spec.backend == "openai_compat": + fields.extend(("extra_query", "proxy")) + if spec.name in _OAUTH_PROXY_PROVIDERS and "proxy" not in fields: + fields.append("proxy") + if spec.name == "openai": + fields.append("api_type") + if spec.backend == "bedrock": + fields.extend(("region", "profile")) + if find_by_name(name) is None: + fields.append("thinking_style") + return fields + + +def _provider_settings_row( + name: str, + spec: Any, + provider_config: ProviderConfig, + oauth_status_reader: OAuthStatusReader, +) -> dict[str, Any]: + oauth_status = oauth_status_reader(spec) if spec.is_oauth else None + is_custom = find_by_name(name) is None + row = { + "name": name, + "label": spec.label, + "is_custom": is_custom, + "configured": ( + bool(oauth_status["configured"]) + if oauth_status is not None + else provider_configured_for_settings(spec, provider_config, oauth_status_reader) + ), + "auth_type": "oauth" if spec.is_oauth else "api_key", + "api_key_required": provider_requires_api_key(spec), + "api_key_hint": mask_secret_hint(provider_config.api_key), + "api_base": provider_config.api_base, + "default_api_base": spec.default_api_base or None, + "model_selectable": not spec.is_transcription_only, + "model_catalog": model_catalog_kind(spec), + "advanced_fields": _provider_advanced_field_names(name, spec), + "extra_headers": _redact_provider_secret_values(provider_config.extra_headers), + "extra_body": _redact_provider_secret_values(provider_config.extra_body), + "extra_query": _redact_provider_secret_values(provider_config.extra_query), + "thinking_style": provider_config.thinking_style, + "region": getattr(provider_config, "region", None), + "profile": getattr(provider_config, "profile", None), + "proxy": provider_config.proxy, + } + if oauth_status is not None: + row["oauth_account"] = oauth_status["account"] + row["oauth_expires_at"] = oauth_status["expires_at"] + row["oauth_login_supported"] = oauth_status["login_supported"] + if spec.name == "openai": + row["api_type"] = provider_config.api_type + return row + + +def _provider_settings_rows( + config: Config, + selected_provider: str | None, + oauth_status: OAuthStatusReader, +) -> list[dict[str, Any]]: + """Return one Settings row per provider family while preserving legacy configs.""" + aliases: dict[str, list[Any]] = {} + for spec in PROVIDERS: + if spec.settings_alias_for: + aliases.setdefault(spec.settings_alias_for, []).append(spec) + + rows: list[dict[str, Any]] = [] + for canonical in PROVIDERS: + if canonical.settings_alias_for: + continue + candidates = [canonical, *aliases.get(canonical.name, [])] + chosen = next((spec for spec in candidates if spec.name == selected_provider), None) + if chosen is None: + chosen = next( + ( + spec + for spec in candidates + if (provider_config := getattr(config.providers, spec.name, None)) is not None + and provider_configured_for_settings(spec, provider_config, oauth_status) + ), + canonical, + ) + provider_config = getattr(config.providers, chosen.name, None) + if provider_config is None: + continue + row = _provider_settings_row(chosen.name, chosen, provider_config, oauth_status) + row["label"] = canonical.label + rows.append(row) + return rows + + +def model_catalog_kind(spec: Any) -> str: + catalog = getattr(spec, "model_catalog", "auto") + if catalog != "auto": + return catalog + if spec.is_transcription_only or spec.is_oauth: + return "unsupported" + if spec.backend != "openai_compat" and spec.name != "minimax_anthropic": + return "unsupported" + if spec.is_local: + return "local" + if spec.is_direct: + return "custom" + if spec.is_gateway: + return "catalog" + return "official" + + +def _model_id_from_row(row: Any) -> str | None: + if isinstance(row, str): + return row.strip() or None + if not isinstance(row, dict): + return None + row_mapping = cast(dict[str, Any], row) + for key in ("id", "name", "model"): + value = row_mapping.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _model_context_window(row: Any) -> int | None: + if not isinstance(row, dict): + return None + row_mapping = cast(dict[str, Any], row) + for key in ( + "context_window", + "context_length", + "max_context_length", + "max_model_len", + "max_input_tokens", + ): + value = row_mapping.get(key) + if isinstance(value, int) and value > 0: + return value + if isinstance(value, float) and value > 0: + return int(value) + return None + + +def _model_row_payload(row: Any) -> dict[str, Any] | None: + model_id = _model_id_from_row(row) + if not model_id: + return None + label: str | None = None + description: str | None = None + owned_by: str | None = None + if isinstance(row, dict): + row_mapping = cast(dict[str, Any], row) + raw_label = row_mapping.get("display_name") or row_mapping.get("label") or row_mapping.get("name") + if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id: + label = raw_label.strip() + raw_description = row_mapping.get("description") + if isinstance(raw_description, str) and raw_description.strip(): + description = raw_description.strip() + raw_owner = row_mapping.get("owned_by") or row_mapping.get("owner") or row_mapping.get("organization") + if isinstance(raw_owner, str) and raw_owner.strip(): + owned_by = raw_owner.strip() + payload = { + "id": model_id, + "label": label, + "owned_by": owned_by, + "context_window": _model_context_window(row), + } + if description: + payload["description"] = description + return payload + + +def _extract_model_rows(body: Any) -> list[dict[str, Any]]: + raw_rows = cast(dict[str, Any], body).get("data") if isinstance(body, dict) else body + if not isinstance(raw_rows, list): + return [] + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for raw_row in cast(list[object], raw_rows): + row = _model_row_payload(raw_row) + if row is None or row["id"] in seen: + continue + seen.add(row["id"]) + rows.append(row) + return rows + + +def provider_models_payload( + config: Config, + query: QueryParams, + *, + http_get: HttpGet, +) -> dict[str, Any]: + """Fetch an advisory model list without mutating configuration.""" + provider_name = (query_first(query, "provider") or "").strip() + if not provider_name: + raise WebUISettingsError("provider is required") + + resolved_provider = resolve_settings_provider(config, provider_name) + if resolved_provider is None: + raise WebUISettingsError("unknown provider") + spec, provider_key, provider_config = resolved_provider + + catalog_kind = model_catalog_kind(spec) + base_payload: dict[str, Any] = { + "provider": provider_key, + "label": spec.label, + "catalog_kind": catalog_kind, + "models": [], + "model_count": 0, + "message": None, + "fetched_at": time.time(), + } + if catalog_kind == "unsupported": + return { + **base_payload, + "status": "unsupported", + "message": "Model list is not available for this provider. Type a model ID manually.", + } + if catalog_kind == "builtin": + rows = [ + { + "id": model.id, + "label": model.label or None, + "description": model.description or None, + "owned_by": spec.label, + "context_window": model.context_window, + } + for model in spec.builtin_models + ] + return { + **base_payload, + "status": "available", + "models": rows, + "model_count": len(rows), + } + + api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base + if spec.name == "openai" and not api_base: + api_base = "https://api.openai.com/v1" + if not api_base: + return { + **base_payload, + "status": "missing_api_base", + "message": "Configure an API base URL to load models.", + } + + api_key = _resolve_env_placeholders(provider_config.api_key) + if provider_requires_api_key(spec) and not api_key: + return { + **base_payload, + "status": "not_configured", + "message": "Configure this provider before loading models.", + } + + headers = {"Accept": "application/json"} + if api_key: + if spec.name == "minimax_anthropic": + headers["X-Api-Key"] = api_key + else: + headers["Authorization"] = f"Bearer {api_key}" + + models_url = f"{api_base.rstrip('/')}/models" + if spec.name == "minimax_anthropic" and not api_base.rstrip("/").endswith("/v1"): + models_url = f"{api_base.rstrip('/')}/v1/models" + + try: + response = http_get( + models_url, + headers=headers, + timeout=10.0, + follow_redirects=False, + ) + response.raise_for_status() + rows = _extract_model_rows(response.json()) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status in {401, 403}: + return { + **base_payload, + "status": "not_configured", + "message": "The provider rejected the configured credential.", + } + return { + **base_payload, + "status": "error", + "message": f"Model list request failed with HTTP {status}.", + } + except (httpx.HTTPError, ValueError) as exc: + return { + **base_payload, + "status": "error", + "message": f"Could not load models: {exc}", + } + + return { + **base_payload, + "status": "available", + "models": rows, + "model_count": len(rows), + } + + +def _parse_context_window_tokens(value: str | None) -> int | None: + if value is None: + return None + try: + parsed = int(value) + except ValueError: + raise WebUISettingsError("context_window_tokens must be an integer") from None + if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS: + raise WebUISettingsError( + "context_window_tokens must be 65536, 200000, 262144, 500000, or 1048576" + ) + return parsed + + +def _parse_positive_int(value: str | None, field: str) -> int | None: + if value is None: + return None + try: + parsed = int(value) + except ValueError: + raise WebUISettingsError(f"{field} must be an integer") from None + if parsed <= 0: + raise WebUISettingsError(f"{field} must be greater than zero") + return parsed + + +def _parse_temperature(value: str | None) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except ValueError: + raise WebUISettingsError("temperature must be a number") from None + if not math.isfinite(parsed) or parsed < 0 or parsed > 2: + raise WebUISettingsError("temperature must be between 0 and 2") + return parsed + + +def _model_configuration_slug(label: str) -> str: + normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower()) + normalized = normalized.strip("-_") + if not normalized: + raise WebUISettingsError("configuration name is required") + if normalized == "default": + raise WebUISettingsError("configuration name is reserved") + if len(normalized) > 48: + normalized = normalized[:48].rstrip("-_") + return normalized + + +def _custom_provider_key(config: Config, display_name: str) -> str: + slug = _MODEL_CONFIGURATION_SLUG_RE.sub("-", display_name.strip().lower()).strip("-_") + base = f"custom-{slug or 'provider'}" + if len(base) > 56: + base = base[:56].rstrip("-_") + existing = { + name.replace("_", "-").lower() + for name, _provider_config in _dynamic_provider_items(config) + } + candidate = base + suffix = 2 + while candidate.replace("_", "-").lower() in existing or find_by_name(candidate): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def _provider_display_name_exists( + config: Config, + display_name: str, + *, + exclude_key: str | None = None, +) -> bool: + normalized = display_name.strip().casefold() + if any(spec.label.strip().casefold() == normalized for spec in PROVIDERS): + return True + for provider_key, provider_config in _dynamic_provider_items(config): + if provider_key == exclude_key: + continue + label = ( + provider_config.display_name + or provider_key.replace("-", " ").replace("_", " ").title() + ) + if label.strip().casefold() == normalized: + return True + return False + + +def _unique_model_configuration_name(config: Config, label: str) -> str: + """Return a stable, unused preset name for a migrated model configuration.""" + try: + base = _model_configuration_slug(label) + except WebUISettingsError: + base = "model" + candidate = base + suffix = 2 + while candidate in config.model_presets: + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def _model_configuration_label(model: str) -> str: + return model.rsplit("/", 1)[-1] or model + + +def _model_call_order_state(config: Config) -> tuple[list[str], bool]: + defaults = config.agents.defaults + primary = defaults.model_preset + if not primary or primary == "default" or primary not in config.model_presets: + return [], False + order = [primary] + for fallback in defaults.fallback_models: + if not isinstance(fallback, str): + return [], False + order.append(fallback) + return order, True + + +def _validate_configured_provider( + config: Config, + provider: str, + oauth_status: OAuthStatusReader, +) -> None: + if provider == "auto": + return + resolved_provider = resolve_settings_provider(config, provider) + if resolved_provider is None: + raise WebUISettingsError("unknown provider") + spec, _, provider_config = resolved_provider + if spec.is_transcription_only: + raise WebUISettingsError("provider does not support chat models") + if not provider_configured_for_settings(spec, provider_config, oauth_status): + raise WebUISettingsError("provider is not configured") + + +_DEFAULT_REASONING_EFFORT_VALUES: tuple[str, ...] = ("", "low", "medium", "high") + + +def reasoning_effort_values_for(provider_name: str, model: str) -> list[str]: + """Return user-facing reasoning_effort options for this provider+model.""" + spec = find_by_name(provider_name) if provider_name else None + if spec is None: + return list(_DEFAULT_REASONING_EFFORT_VALUES) + + model_lower = (model or "").lower() + if model_lower.rsplit("/", 1)[-1] == "kimi-k3": + return ["", "max"] + + implicit = getattr(spec, "implicit_reasoning_models", ()) + if implicit and any(pattern in model_lower for pattern in implicit): + return [""] + + remap = getattr(spec, "reasoning_effort_remap", ()) + if remap: + wire_values: list[str] = [] + for _user_value, wire_value in remap: + if wire_value and wire_value != "none" and wire_value not in wire_values: + wire_values.append(wire_value) + return ["", *wire_values] + + return list(_DEFAULT_REASONING_EFFORT_VALUES) + + +def model_settings_payload( + config: Config, + *, + oauth_status: OAuthStatusReader, +) -> ModelSettingsPayload: + defaults = config.agents.defaults + active_preset_name = defaults.model_preset or "default" + effective_preset = config.resolve_preset() + provider_name = ( + config.get_provider_name(effective_preset.model, preset=effective_preset) + or effective_preset.provider + ) + provider = config.get_provider(effective_preset.model, preset=effective_preset) + selected_provider = provider_name + if effective_preset.provider != "auto": + spec = find_by_name(effective_preset.provider) + selected_provider = spec.name if spec else provider_name + + providers = _provider_settings_rows(config, selected_provider, oauth_status) + for provider_key, provider_config in _dynamic_provider_items(config): + providers.append( + _provider_settings_row( + provider_key, + create_dynamic_spec( + provider_key, + display_name=provider_config.display_name or "", + thinking_style=provider_config.thinking_style or "", + ), + provider_config, + oauth_status, + ) + ) + + model_presets = [ + { + "name": "default", + "label": "Default", + "active": active_preset_name == "default", + "is_default": True, + "model": defaults.model, + "provider": defaults.provider, + "resolved_provider": config.get_provider_name( + defaults.model, + preset=config.resolve_default_preset(), + ), + "max_tokens": defaults.max_tokens, + "context_window_tokens": defaults.context_window_tokens, + "temperature": defaults.temperature, + "reasoning_effort": defaults.reasoning_effort, + "reasoning_effort_values": reasoning_effort_values_for( + config.get_provider_name( + defaults.model, + preset=config.resolve_default_preset(), + ) + or defaults.provider, + defaults.model, + ), + } + ] + for name, preset in config.model_presets.items(): + resolved_preset_provider = ( + config.get_provider_name(preset.model, preset=preset) or preset.provider + ) + model_presets.append( + { + "name": name, + "label": preset.label or name, + "active": active_preset_name == name, + "is_default": False, + "model": preset.model, + "provider": preset.provider, + "resolved_provider": resolved_preset_provider, + "max_tokens": preset.max_tokens, + "context_window_tokens": preset.context_window_tokens, + "temperature": preset.temperature, + "reasoning_effort": preset.reasoning_effort, + "reasoning_effort_values": reasoning_effort_values_for( + resolved_preset_provider, + preset.model, + ), + } + ) + + model_call_order, model_call_order_editable = _model_call_order_state(config) + return { + "agent": { + "model": effective_preset.model, + "provider": selected_provider, + "resolved_provider": provider_name, + "has_api_key": bool(provider and provider.api_key), + "model_preset": active_preset_name, + "max_tokens": effective_preset.max_tokens, + "context_window_tokens": effective_preset.context_window_tokens, + "temperature": effective_preset.temperature, + "reasoning_effort": effective_preset.reasoning_effort, + "timezone": defaults.timezone, + "tool_hint_max_length": defaults.tool_hint_max_length, + }, + "model_presets": model_presets, + "model_call_order": model_call_order, + "model_call_order_editable": model_call_order_editable, + "providers": providers, + } + + +def update_agent_model_settings( + config: Config, + query: QueryParams, + *, + oauth_status: OAuthStatusReader, +) -> bool: + defaults = config.agents.defaults + changed = False + + if "model_preset" in query or "modelPreset" in query: + preset = (query_first_alias(query, "model_preset", "modelPreset") or "").strip() + preset_value = None if not preset or preset == "default" else preset + if preset_value is not None and preset_value not in config.model_presets: + raise WebUISettingsError("unknown model preset") + if defaults.model_preset != preset_value: + defaults.model_preset = preset_value + changed = True + + model = query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + raise WebUISettingsError("model is required") + if defaults.model != model: + defaults.model = model + changed = True + + provider = query_first(query, "provider") + if provider is not None: + provider = provider.strip() + if not provider: + raise WebUISettingsError("provider is required") + _validate_configured_provider(config, provider, oauth_status) + if defaults.provider != provider: + defaults.provider = provider + changed = True + + context_window_tokens = _parse_context_window_tokens( + query_first_alias(query, "context_window_tokens", "contextWindowTokens") + ) + if ( + context_window_tokens is not None + and defaults.context_window_tokens != context_window_tokens + ): + defaults.context_window_tokens = context_window_tokens + changed = True + return changed + + +def create_model_configuration( + config: Config, + query: QueryParams, + *, + oauth_status: OAuthStatusReader, +) -> str: + label = (query_first_alias(query, "label", "displayName") or "").strip() + raw_name = (query_first(query, "name") or label).strip() + model = (query_first(query, "model") or "").strip() + provider = (query_first(query, "provider") or "").strip() + + if not label: + label = raw_name + if not model: + raise WebUISettingsError("model is required") + if not provider: + raise WebUISettingsError("provider is required") + + name = _model_configuration_slug(raw_name or label) + if name in config.model_presets: + raise WebUISettingsError("configuration already exists", status=409) + _validate_configured_provider(config, provider, oauth_status) + + base = config.resolve_preset() + max_tokens = _parse_positive_int( + query_first_alias(query, "max_tokens", "maxTokens"), + "max_tokens", + ) + context_window_tokens = _parse_positive_int( + query_first_alias(query, "context_window_tokens", "contextWindowTokens"), + "context_window_tokens", + ) + temperature = _parse_temperature(query_first(query, "temperature")) + reasoning_effort = base.reasoning_effort + if "reasoning_effort" in query or "reasoningEffort" in query: + reasoning_effort = ( + query_first_alias(query, "reasoning_effort", "reasoningEffort") or "" + ).strip() or None + config.model_presets[name] = ModelPresetConfig( + label=label, + model=model, + provider=provider, + max_tokens=max_tokens if max_tokens is not None else base.max_tokens, + context_window_tokens=( + context_window_tokens + if context_window_tokens is not None + else base.context_window_tokens + ), + temperature=temperature if temperature is not None else base.temperature, + reasoning_effort=reasoning_effort, + ) + return name + + +def update_model_configuration( + config: Config, + query: QueryParams, + *, + oauth_status: OAuthStatusReader, +) -> bool: + name = (query_first(query, "name") or "").strip() + if not name or name == "default": + raise WebUISettingsError("model configuration is required") + + preset = config.model_presets.get(name) + if preset is None: + raise WebUISettingsError("unknown model configuration") + + changed = False + label = query_first_alias(query, "label", "displayName") + if label is not None: + label = label.strip() + if not label: + raise WebUISettingsError("label is required") + if preset.label != label: + preset.label = label + changed = True + + model = query_first(query, "model") + if model is not None: + model = model.strip() + if not model: + raise WebUISettingsError("model is required") + if preset.model != model: + preset.model = model + changed = True + + provider = query_first(query, "provider") + if provider is not None: + provider = provider.strip() + if not provider: + raise WebUISettingsError("provider is required") + _validate_configured_provider(config, provider, oauth_status) + if preset.provider != provider: + preset.provider = provider + changed = True + + context_window_tokens = _parse_positive_int( + query_first_alias(query, "context_window_tokens", "contextWindowTokens"), + "context_window_tokens", + ) + if ( + context_window_tokens is not None + and preset.context_window_tokens != context_window_tokens + ): + preset.context_window_tokens = context_window_tokens + changed = True + + max_tokens = _parse_positive_int( + query_first_alias(query, "max_tokens", "maxTokens"), + "max_tokens", + ) + if max_tokens is not None and preset.max_tokens != max_tokens: + preset.max_tokens = max_tokens + changed = True + + temperature = _parse_temperature(query_first(query, "temperature")) + if temperature is not None and preset.temperature != temperature: + preset.temperature = temperature + changed = True + + if "reasoning_effort" in query or "reasoningEffort" in query: + reasoning_effort = ( + query_first_alias(query, "reasoning_effort", "reasoningEffort") or "" + ).strip() or None + if preset.reasoning_effort != reasoning_effort: + preset.reasoning_effort = reasoning_effort + changed = True + return changed + + +def update_model_call_order(config: Config, query: QueryParams) -> bool: + raw_order = query_first_alias(query, "order", "presetNames") + if raw_order is None: + raise WebUISettingsError("model call order is required") + try: + order: object = json.loads(raw_order) + except json.JSONDecodeError: + raise WebUISettingsError("model call order must be a JSON array") from None + if ( + not isinstance(order, list) + or not order + or any( + not isinstance(name, str) or not name.strip() + for name in cast(list[object], order) + ) + ): + raise WebUISettingsError("model call order must contain at least one preset") + + normalized_order = [cast(str, name).strip() for name in cast(list[object], order)] + _, editable = _model_call_order_state(config) + if not editable: + raise WebUISettingsError( + "convert the existing model configuration to presets first", + status=409, + ) + unknown = [name for name in normalized_order if name not in config.model_presets] + if unknown: + raise WebUISettingsError(f"unknown model preset: {unknown[0]}") + + defaults = config.agents.defaults + fallback_models: list[FallbackCandidate] = list(normalized_order[1:]) + changed = ( + defaults.model_preset != normalized_order[0] + or defaults.fallback_models != fallback_models + ) + if changed: + defaults.model_preset = normalized_order[0] + defaults.fallback_models = fallback_models + return changed + + +def migrate_model_configurations(config: Config) -> bool: + """Materialize legacy primary/inline model settings as named presets.""" + defaults = config.agents.defaults + primary = config.resolve_preset() + created: list[str] = [] + + if not defaults.model_preset or defaults.model_preset == "default": + label = _model_configuration_label(primary.model) + name = _unique_model_configuration_name(config, label) + config.model_presets[name] = ModelPresetConfig( + label=label, + model=primary.model, + provider=primary.provider, + max_tokens=primary.max_tokens, + context_window_tokens=primary.context_window_tokens, + temperature=primary.temperature, + reasoning_effort=primary.reasoning_effort, + ) + defaults.model_preset = name + created.append(name) + + fallback_models: list[FallbackCandidate] = [] + for fallback in defaults.fallback_models: + if isinstance(fallback, str): + fallback_models.append(fallback) + continue + label = _model_configuration_label(fallback.model) + name = _unique_model_configuration_name(config, label) + config.model_presets[name] = ModelPresetConfig( + label=label, + model=fallback.model, + provider=fallback.provider, + max_tokens=( + fallback.max_tokens if fallback.max_tokens is not None else primary.max_tokens + ), + context_window_tokens=( + fallback.context_window_tokens + if fallback.context_window_tokens is not None + else primary.context_window_tokens + ), + temperature=( + fallback.temperature + if fallback.temperature is not None + else primary.temperature + ), + reasoning_effort=fallback.reasoning_effort, + ) + fallback_models.append(name) + created.append(name) + + if created: + defaults.fallback_models = fallback_models + return bool(created) + + +def delete_model_configuration(config: Config, query: QueryParams) -> None: + name = (query_first(query, "name") or "").strip() + if not name or name == "default": + raise WebUISettingsError("model configuration is required") + if name not in config.model_presets: + raise WebUISettingsError("unknown model configuration") + defaults = config.agents.defaults + referenced = defaults.model_preset == name or any( + fallback == name for fallback in defaults.fallback_models + ) + if referenced: + raise WebUISettingsError( + "remove the model preset from the call order first", + status=409, + ) + del config.model_presets[name] + + +def create_provider_settings(config: Config, query: QueryParams) -> str: + display_name = (query_first_alias(query, "name", "displayName") or "").strip() + if not display_name: + raise WebUISettingsError("provider name is required") + if len(display_name) > 80: + raise WebUISettingsError("provider name must be 80 characters or fewer") + updates = _provider_config_updates(query) + allowed = { + "api_key", + "api_base", + "proxy", + "extra_headers", + "extra_body", + "extra_query", + "thinking_style", + "display_name", + } + unsupported = set(updates) - allowed + if unsupported: + field = sorted(unsupported)[0] + raise WebUISettingsError(f"{field} is not supported for a custom provider") + api_base = str(updates.get("api_base") or "") + if not api_base: + raise WebUISettingsError("API base is required") + if _provider_display_name_exists(config, display_name): + raise WebUISettingsError("provider already exists", status=409) + + provider_key = _custom_provider_key(config, display_name) + updates["display_name"] = display_name + updates["api_type"] = "auto" + provider_config = _validated_provider_config(None, updates) + setattr(config.providers, provider_key, provider_config) + return provider_key + + +def update_provider_settings( + config: Config, + query: QueryParams, +) -> tuple[bool, bool]: + provider_name = (query_first(query, "provider") or "").strip() + if not provider_name: + raise WebUISettingsError("provider is required") + + resolved_provider = resolve_settings_provider(config, provider_name) + if resolved_provider is None: + raise WebUISettingsError("unknown provider") + spec, provider_key, provider_config = resolved_provider + updates = _provider_config_updates(query) + if not spec.is_oauth and spec.name != "openai": + updates.pop("api_type", None) + if spec.is_oauth: + if spec.name not in _OAUTH_PROXY_PROVIDERS: + raise WebUISettingsError("unknown provider") + unsupported = set(updates) - {"proxy", "extra_body"} + if unsupported: + raise WebUISettingsError( + "OAuth provider only supports proxy and extra_body settings" + ) + else: + allowed = { + "api_key", + "api_base", + *_provider_advanced_field_names(provider_key, spec), + } + if find_by_name(provider_key) is None: + allowed.add("display_name") + unsupported = set(updates) - allowed + if unsupported: + field = sorted(unsupported)[0] + raise WebUISettingsError(f"{field} is not supported for this provider") + + if "display_name" in updates: + display_name = str(updates["display_name"] or "") + if not display_name: + raise WebUISettingsError("provider name is required") + if len(display_name) > 80: + raise WebUISettingsError("provider name must be 80 characters or fewer") + if _provider_display_name_exists(config, display_name, exclude_key=provider_key): + raise WebUISettingsError("provider already exists", status=409) + + updated_provider_config = _validated_provider_config(provider_config, updates) + changed = updated_provider_config != provider_config + if changed: + setattr(config.providers, provider_key, updated_provider_config) + image_config = config.tools.image_generation + restart_required = ( + changed + and image_config.enabled + and image_config.provider == provider_key + and get_image_gen_provider(provider_key) is not None + ) + return changed, restart_required + + +def login_oauth_provider( + config: Config, + query: QueryParams, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None, + settings_payload: SettingsPayloadBuilder, +) -> dict[str, Any]: + provider_name = (query_first(query, "provider") or "").strip() + if not provider_name: + raise WebUISettingsError("provider is required") + spec = find_by_name(provider_name) + if spec is None or not spec.is_oauth: + raise WebUISettingsError("unknown OAuth provider") + + if spec.name == "openai_codex": + try: + from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login + except ImportError: + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None + + try: + proxy = resolve_config_env_vars( + config, + config_path=config_path, + ).providers.openai_codex.proxy or None + except ValueError as exc: + raise WebUISettingsError(str(exc), status=400) from exc + remote_browser_value = query_first(query, "remote_browser") + remote_browser = ( + parse_bool(remote_browser_value, "remote_browser") + if remote_browser_value is not None + else False + ) + try: + flow = start_openai_codex_oauth_login( + proxy=proxy, + timeout_s=_WEBUI_OAUTH_TIMEOUT_S, + open_browser=not remote_browser, + ) + except Exception as exc: + raise WebUISettingsError( + f"OpenAI Codex OAuth login failed: {exc}", + status=502, + ) from exc + flow_id = secrets.token_urlsafe(24) + oauth_flows.register(spec.name, flow_id, flow) + return { + "status": "authorization_required", + "provider": spec.name, + "flow_id": flow_id, + "authorization_url": flow.authorization_url, + "expires_in": flow.remaining_seconds, + "completion_input": "callback_url", + } + + if spec.name == "github_copilot": + try: + from nanobot.providers.github_copilot_provider import ( + get_github_copilot_login_status, + login_github_copilot, + ) + except ImportError: + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None + + token = get_github_copilot_login_status() + if not token: + token = login_github_copilot(print_fn=lambda _message: None) + if not (token and token.access): + raise WebUISettingsError("OAuth login failed", status=401) + return settings_payload(config_path=config_path) + + if spec.name == "xai_grok": + from nanobot.providers.xai_oauth import start_xai_oauth_login + + try: + proxy = resolve_config_env_vars( + config, + config_path=config_path, + ).providers.xai_grok.proxy or None + except ValueError as exc: + raise WebUISettingsError(str(exc), status=400) from exc + try: + flow = start_xai_oauth_login( + proxy=proxy, + timeout_s=_WEBUI_OAUTH_TIMEOUT_S, + ) + except Exception as exc: + raise WebUISettingsError(f"xAI OAuth login failed: {exc}", status=502) from exc + flow_id = secrets.token_urlsafe(24) + oauth_flows.register(spec.name, flow_id, flow) + return { + "status": "authorization_required", + "provider": spec.name, + "flow_id": flow_id, + "authorization_url": flow.authorization_url, + "expires_in": flow.remaining_seconds, + "completion_input": "authorization_code", + } + + raise WebUISettingsError("OAuth login is not supported for this provider") + + +def complete_oauth_provider( + query: QueryParams, + authorization_response: str | None = None, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None, + settings_payload: SettingsPayloadBuilder, +) -> dict[str, Any]: + provider_name = (query_first(query, "provider") or "").strip() + flow_id = (query_first(query, "flow_id") or "").strip() + spec = find_by_name(provider_name) + if spec is None or spec.name not in {"openai_codex", "xai_grok"}: + raise WebUISettingsError("OAuth completion is not supported for this provider") + if not flow_id: + raise WebUISettingsError("flow_id is required") + + flow = oauth_flows.get(spec.name, flow_id) + if flow is None: + raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410) + + try: + if spec.name == "openai_codex": + from nanobot.providers.openai_codex_oauth import ( + OpenAICodexOAuthInputError, + complete_openai_codex_oauth_login, + ) + + try: + token = complete_openai_codex_oauth_login(flow, authorization_response) + except OpenAICodexOAuthInputError as exc: + raise WebUISettingsError(str(exc), status=400) from exc + else: + from nanobot.providers.xai_oauth import complete_xai_oauth_login + + token = complete_xai_oauth_login(flow, authorization_response) + except WebUISettingsError: + raise + except Exception as exc: + oauth_flows.remove(spec.name, flow_id, flow) + raise WebUISettingsError( + f"{spec.label} OAuth login failed: {exc}", + status=502, + ) from exc + if token is None: + return { + "status": "pending", + "provider": spec.name, + "flow_id": flow_id, + } + oauth_flows.remove(spec.name, flow_id, flow, cancel=False) + if not token.access: + raise WebUISettingsError("OAuth login failed", status=401) + return settings_payload(config_path=config_path) + + +def logout_oauth_provider( + query: QueryParams, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None, + settings_payload: SettingsPayloadBuilder, +) -> dict[str, Any]: + provider_name = (query_first(query, "provider") or "").strip() + if not provider_name: + raise WebUISettingsError("provider is required") + spec = find_by_name(provider_name) + if spec is None or not spec.is_oauth: + raise WebUISettingsError("unknown OAuth provider") + + if spec.name == "openai_codex": + try: + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + except ImportError: + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None + oauth_flows.clear(spec.name) + token_path = FileTokenStorage( + token_filename=OPENAI_CODEX_PROVIDER.token_filename + ).get_token_path() + elif spec.name == "github_copilot": + try: + from nanobot.providers.github_copilot_provider import get_storage + except ImportError: + raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None + token_path = get_storage().get_token_path() + elif spec.name == "xai_grok": + from nanobot.providers.xai_oauth import logout_xai_oauth + + oauth_flows.clear(spec.name) + logout_xai_oauth() + return settings_payload(config_path=config_path) + else: + raise WebUISettingsError("OAuth logout is not supported for this provider") + + for path in (token_path, token_path.with_suffix(".lock")): + with suppress(FileNotFoundError): + path.unlink() + return settings_payload(config_path=config_path) + + +class ModelSettingsHandler: + """Handle model/provider commands after transport authentication and decoding.""" + + def __init__(self, settings: WebUISettingsServices, logger: Any) -> None: + self.settings = settings + self.logger = logger + + async def handle( + self, + action: str, + request: SettingsRequest, + operations: ModelSettingsOperations, + ) -> SettingsRouteResult: + try: + if action == "agent-update": + payload = self.settings.mutate(operations.update_agent, request.query) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="runtime", + ) + + mutation = { + "model-create": operations.create_model, + "model-update": operations.update_model, + "model-delete": operations.delete_model, + "models-migrate": operations.migrate_models, + "call-order-update": operations.update_call_order, + "provider-create": operations.create_provider, + }.get(action) + if mutation is not None: + payload = self.settings.mutate(mutation, request.query) + return SettingsRouteResult.success(payload, decorate_restart=True) + + if action == "provider-update": + payload = self.settings.mutate( + operations.update_provider, + request.query, + ) + payload, image_restart_cleared = await operations.apply_image_runtime_change( + payload + ) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="image", + clear_restart_section=( + "image" if image_restart_cleared else None + ), + ) + + if action == "provider-models": + try: + payload = await asyncio.to_thread( + self.settings.read, + operations.provider_models, + request.query, + ) + except WebUISettingsError: + raise + except Exception: + self.logger.exception("failed to load provider model list") + return SettingsRouteResult.failure( + 500, + "failed to load provider model list", + ) + return SettingsRouteResult.success(payload) + + if action == "oauth-login": + payload = await asyncio.to_thread( + self.settings.read, + operations.oauth_login, + request.query, + oauth_flows=self.settings.oauth_flows, + ) + elif action == "oauth-complete": + raw_response = (request.payload or {}).get("authorization_response") + if raw_response is not None and not isinstance(raw_response, str): + raise WebUISettingsError( + "OAuth authorization response must be a string" + ) + payload = await asyncio.to_thread( + self.settings.read, + operations.oauth_complete, + request.query, + raw_response or None, + oauth_flows=self.settings.oauth_flows, + ) + elif action == "oauth-logout": + payload = await asyncio.to_thread( + self.settings.read, + operations.oauth_logout, + request.query, + oauth_flows=self.settings.oauth_flows, + ) + else: + return SettingsRouteResult.failure(404, "unknown settings action") + except WebUISettingsError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + + if payload.get("status") in {"authorization_required", "pending"}: + return SettingsRouteResult.success(payload) + return SettingsRouteResult.success(payload, decorate_restart=True) diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 5ca62e5f2..104fd0feb 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -1,17 +1,10 @@ -"""HTTP route adapter for WebUI Settings APIs. - -Keep WebUI Settings route handlers here, not in ``channels/websocket.py``. -The websocket channel owns transport concerns; this module owns WebUI Settings -request mapping and response shaping. -""" +"""Stable WebSocket/HTTP dispatcher for WebUI settings domains.""" from __future__ import annotations import asyncio import html -import inspect import json -import time from collections.abc import Callable from typing import Any, cast @@ -21,31 +14,23 @@ from websockets.http11 import Response from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH -from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths +from nanobot.api.runtime import ApiRuntime, api_runtime_paths from nanobot.bus.queue import MessageBus -from nanobot.channels._setup import channel_setup_spec -from nanobot.channels.connect import ChannelConnectError -from nanobot.channels.contracts import ( - RouteFieldType, - channel_instance_config, - channel_update_instance_config, -) from nanobot.channels.registry import load_channel_plugin from nanobot.channels.validation import validate_channel_config -from nanobot.config.schema import Config -from nanobot.optional_features import ( - OptionalFeatureError, - extra_installed, - optional_dependency_groups, - with_channel_runtime_status, -) from nanobot.pairing import approve_code, deny_code, list_pending +from nanobot.webui import settings_capabilities as capability_domain +from nanobot.webui import settings_contracts as contracts +from nanobot.webui import settings_models as model_domain +from nanobot.webui import settings_system as system_domain from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload from nanobot.webui.http_utils import http_response as _http_response from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request -from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.mcp_oauth_api import McpOAuthManager -from nanobot.webui.mcp_presets_api import ensure_mcp_oauth_server, mcp_presets_settings_action +from nanobot.webui.mcp_presets_api import ( + ensure_mcp_oauth_server, + mcp_presets_settings_action, +) from nanobot.webui.nanobot_features_api import ( nanobot_feature_instance_target, nanobot_features_action, @@ -74,17 +59,19 @@ from nanobot.webui.settings_api import ( update_transcription_settings, update_web_search_settings, ) +from nanobot.webui.settings_contracts import ( + QueryParams, + SettingsRequest, + SettingsRouteResult, +) from nanobot.webui.settings_services import WebUISettingsServices from nanobot.webui.version_check import check_for_update -QueryParams = dict[str, list[str]] - _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" - -_SKIP_FIELD = object() _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) _MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024 +_query_first = contracts.query_first def _channel_connect_route(path: str) -> tuple[str, str] | None: @@ -92,11 +79,16 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None: if not path.startswith(prefix): return None parts = path.removeprefix(prefix).split("/") - if len(parts) != 3 or parts[1] != "connect" or parts[2] not in _CHANNEL_CONNECT_ACTIONS: + if ( + len(parts) != 3 + or parts[1] != "connect" + or parts[2] not in _CHANNEL_CONNECT_ACTIONS + ): return None channel_name = parts[0].strip() return (channel_name, parts[2]) if channel_name else None + _MCP_PRESET_ACTIONS_BY_PATH = { "/api/settings/mcp-presets/enable": "enable", "/api/settings/mcp-presets/remove": "remove", @@ -107,6 +99,53 @@ _MCP_PRESET_ACTIONS_BY_PATH = { "/api/settings/mcp-presets/tools": "tools", } +_MODEL_ROUTES = { + "/api/settings/update": "agent-update", + "/api/settings/model-configurations/create": "model-create", + "/api/settings/model-configurations/update": "model-update", + "/api/settings/model-configurations/delete": "model-delete", + "/api/settings/model-configurations/migrate": "models-migrate", + "/api/settings/model-call-order/update": "call-order-update", + "/api/settings/provider/update": "provider-update", + "/api/settings/provider/create": "provider-create", + "/api/settings/provider-models": "provider-models", + "/api/settings/provider/oauth-login": "oauth-login", + "/api/settings/provider/oauth-login/complete": "oauth-complete", + "/api/settings/provider/oauth-logout": "oauth-logout", +} + +_CAPABILITY_ROUTES = { + "/api/settings/web-search/update": "web-search-update", + "/api/settings/api-service": "api-status", + "/api/settings/api-service/start": "api-start", + "/api/settings/api-service/stop": "api-stop", + "/api/settings/image-generation/update": "image-update", + "/api/settings/transcription/update": "transcription-update", + "/api/settings/network-safety/update": "network-update", +} + +_SYSTEM_ROUTES = { + "/api/settings/cli-apps": "cli-list", + "/api/settings/cli-apps/install": "cli-install", + "/api/settings/cli-apps/update": "cli-update", + "/api/settings/cli-apps/uninstall": "cli-uninstall", + "/api/settings/cli-apps/test": "cli-test", + "/api/settings/nanobot-features": "features-list", + "/api/settings/nanobot-features/enable": "features-enable", + "/api/settings/nanobot-features/disable": "features-disable", + "/api/settings/channels/validate": "channel-validate", + "/api/settings/channels/configure": "channel-configure", + "/api/settings/pairing": "pairing-list", + "/api/settings/pairing/approve": "pairing-approve", + "/api/settings/pairing/deny": "pairing-deny", + "/api/settings/mcp-presets": "mcp-list", + "/api/settings/version-check": "version-check", + **{ + path: f"mcp-{action}" + for path, action in _MCP_PRESET_ACTIONS_BY_PATH.items() + }, +} + _SETTINGS_MUTATION_PATHS = frozenset({ "/api/settings/update", "/api/settings/model-configurations/create", @@ -169,7 +208,7 @@ def _payload_query(payload: dict[str, Any]) -> QueryParams: class WebUISettingsRouter: - """Route WebUI Settings HTTP requests behind a transport-neutral boundary.""" + """Authenticate and dispatch settings requests to transport-neutral domains.""" def __init__( self, @@ -201,9 +240,19 @@ class WebUISettingsRouter: self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri self._mcp_oauth = McpOAuthManager() self._restart_sections: set[str] = set() - self._channel_connectors: dict[str, Any] = {} + self._models = model_domain.ModelSettingsHandler(settings, logger) + self._capabilities = capability_domain.CapabilitySettingsHandler( + settings, + logger, + ) + self._system = system_domain.SystemSettingsHandler(settings, logger) - async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None: + async def dispatch( + self, + connection: Any, + request: WsRequest, + path: str, + ) -> Response | None: if self.is_mutation_path(path) and not getattr( request, _WEBUI_MUTATION_REQUEST_ATTR, @@ -215,85 +264,6 @@ class WebUISettingsRouter: ) if path == MCP_OAUTH_CALLBACK_PATH: return self._handle_mcp_oauth_callback(request) - if path == "/api/settings": - return self._handle_settings(request) - if path == "/api/settings/usage": - return self._handle_settings_usage(request) - if path == "/api/settings/update": - return self._handle_settings_update(request) - if path == "/api/settings/model-configurations/create": - return self._handle_settings_model_configuration_create(request) - if path == "/api/settings/model-configurations/update": - return self._handle_settings_model_configuration_update(request) - if path == "/api/settings/model-configurations/delete": - return self._handle_settings_model_configuration_delete(request) - if path == "/api/settings/model-configurations/migrate": - return self._handle_settings_model_configurations_migrate(request) - if path == "/api/settings/model-call-order/update": - return self._handle_settings_model_call_order_update(request) - if path == "/api/settings/provider/update": - return await self._handle_settings_provider_update(request) - if path == "/api/settings/provider/create": - return self._handle_settings_provider_create(request) - if path == "/api/settings/provider-models": - return await self._handle_settings_provider_models(request) - if path == "/api/settings/provider/oauth-login": - return await self._handle_settings_provider_oauth(request, "login") - if path == "/api/settings/provider/oauth-login/complete": - return await self._handle_settings_provider_oauth(request, "complete") - if path == "/api/settings/provider/oauth-logout": - return await self._handle_settings_provider_oauth(request, "logout") - if path == "/api/settings/web-search/update": - return self._handle_settings_web_search_update(request) - if path == "/api/settings/api-service": - return self._handle_settings_api_service(request) - if path == "/api/settings/api-service/start": - return await self._handle_settings_api_service_start(connection, request) - if path == "/api/settings/api-service/stop": - return await self._handle_settings_api_service_stop(request) - if path == "/api/settings/image-generation/update": - return await self._handle_settings_image_generation_update(request) - if path == "/api/settings/transcription/update": - return self._handle_settings_transcription_update(request) - if path == "/api/settings/network-safety/update": - return self._handle_settings_network_safety_update(request) - if path == "/api/settings/cli-apps": - return await self._handle_settings_cli_apps(request) - if path == "/api/settings/cli-apps/install": - return await self._handle_settings_cli_apps_action(request, "install") - if path == "/api/settings/cli-apps/update": - return await self._handle_settings_cli_apps_action(request, "update") - if path == "/api/settings/cli-apps/uninstall": - return await self._handle_settings_cli_apps_action(request, "uninstall") - if path == "/api/settings/cli-apps/test": - return await self._handle_settings_cli_apps_action(request, "test") - if path == "/api/settings/nanobot-features": - return await self._handle_settings_nanobot_features(request) - if path == "/api/settings/nanobot-features/enable": - return await self._handle_settings_nanobot_features_action(connection, request, "enable") - if path == "/api/settings/nanobot-features/disable": - return await self._handle_settings_nanobot_features_action(connection, request, "disable") - channel_connect = _channel_connect_route(path) - if channel_connect is not None: - channel_name, action = channel_connect - return await self._handle_settings_channel_connect( - connection, - request, - channel_name, - action, - ) - if path == "/api/settings/channels/validate": - return await self._handle_settings_channel_validate(request) - if path == "/api/settings/channels/configure": - return await self._handle_settings_channel_configure(connection, request) - if path == "/api/settings/pairing": - return self._handle_settings_pairing(request) - if path == "/api/settings/pairing/approve": - return self._handle_settings_pairing_action(request, "approve") - if path == "/api/settings/pairing/deny": - return self._handle_settings_pairing_action(request, "deny") - if path == "/api/settings/mcp-presets": - return await self._handle_settings_mcp_presets(request) if path == "/api/settings/mcp-oauth/start": return await self._handle_mcp_oauth_start(request) if path == "/api/settings/mcp-oauth/status": @@ -302,19 +272,72 @@ class WebUISettingsRouter: return self._handle_mcp_oauth_complete(request) if path == "/api/settings/mcp-oauth/cancel": return await self._handle_mcp_oauth_cancel(request) - if path == "/api/settings/version-check": - return await self._handle_settings_version_check(request) - mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path) - if mcp_action is not None: - return await self._handle_settings_mcp_presets(request, mcp_action) - return None + + route = self._route(path) + if route is None: + return None + if not self._authorized(request): + return self._unauthorized() + if route == ("root", "settings"): + return self._handle_settings() + if route == ("root", "usage"): + return self._handle_settings_usage() + + domain, action = route + domain_request = self._domain_request( + connection, + request, + needs_local_browser=( + action in { + "api-start", + "features-enable", + "channel-configure", + "channel-connect", + } + ), + ) + if domain == "models": + result = await self._models.handle( + action, + domain_request, + self._model_operations(), + ) + elif domain == "capabilities": + result = await self._capabilities.handle( + action, + domain_request, + self._capability_operations(), + ) + else: + channel_connect = _channel_connect_route(path) + result = await self._system.handle( + action, + domain_request, + self._system_operations(), + channel_name=(channel_connect[0] if channel_connect else None), + connect_action=(channel_connect[1] if channel_connect else None), + ) + return self._render_result(result) @staticmethod def is_mutation_path(path: str) -> bool: - return ( - path in _SETTINGS_MUTATION_PATHS - or _channel_connect_route(path) is not None - ) + return path in _SETTINGS_MUTATION_PATHS or _channel_connect_route(path) is not None + + @staticmethod + def _route(path: str) -> tuple[str, str] | None: + if path == "/api/settings": + return "root", "settings" + if path == "/api/settings/usage": + return "root", "usage" + if action := _MODEL_ROUTES.get(path): + return "models", action + if action := _CAPABILITY_ROUTES.get(path): + return "capabilities", action + if action := _SYSTEM_ROUTES.get(path): + return "system", action + if _channel_connect_route(path) is not None: + return "system", "channel-connect" + return None def _query(self, request: WsRequest) -> QueryParams: payload = _mutation_payload(request) @@ -322,6 +345,23 @@ class WebUISettingsRouter: return _payload_query(payload) return self._parse_query(request.path) + def _domain_request( + self, + connection: Any, + request: WsRequest, + *, + needs_local_browser: bool, + ) -> SettingsRequest: + return SettingsRequest( + query=self._query(request), + payload=_mutation_payload(request), + local_browser=( + _is_local_browser_request(connection, request.headers) + if needs_local_browser + else False + ), + ) + def _authorized(self, request: WsRequest) -> bool: return self._check_api_token(request) @@ -334,29 +374,43 @@ class WebUISettingsRouter: *, section: str | None = None, ) -> dict[str, Any]: - """Keep restart-required state alive for this gateway process.""" if section and payload.get("requires_restart"): self._restart_sections.add(section) sections = sorted(self._restart_sections) - payload = dict(payload) + updated = dict(payload) if sections: - payload["requires_restart"] = True + updated["requires_restart"] = True return decorate_settings_payload( - payload, + updated, surface=self._runtime_surface, runtime_capability_overrides=self._runtime_capabilities, restart_required_sections=sections, ) - def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: - return self._query(request) + def _render_result(self, result: SettingsRouteResult) -> Response: + if result.error is not None: + return self._error_response(result.status, result.error) + assert result.payload is not None + payload = result.payload + if result.clear_restart_section: + self._restart_sections.discard(result.clear_restart_section) + if result.decorate_restart: + if result.restart_payload_key: + nested = payload.get(result.restart_payload_key) + if isinstance(nested, dict): + payload = dict(payload) + payload[result.restart_payload_key] = self._with_restart_state( + cast(dict[str, Any], nested), + section=result.restart_section, + ) + else: + payload = self._with_restart_state( + payload, + section=result.restart_section, + ) + return self._json_response(payload) - def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams: - return self._query(request) - - def _handle_settings(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() + def _handle_settings(self) -> Response: return self._json_response( self._with_restart_state( self.settings.read( @@ -367,260 +421,84 @@ class WebUISettingsRouter: ) ) - def _handle_settings_usage(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() + def _handle_settings_usage(self) -> Response: return self._json_response(self.settings.read(settings_usage_payload)) - def _handle_settings_pairing(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - return self._json_response(_pairing_payload()) - - def _handle_settings_pairing_action(self, request: WsRequest, action: str) -> Response: - if not self._authorized(request): - return self._unauthorized() - query = self._query(request) - code = (_query_first(query, "code") or "").strip() - if not code: - return self._error_response(400, "Missing pairing code") - - if action == "approve": - result = approve_code(code) - if result is None: - return self._error_response(404, "Pairing code not found or expired") - channel, sender_id = result - return self._json_response( - _pairing_payload({ - "ok": True, - "action": "approve", - "message": f"Approved {sender_id} for {channel}", - "channel": channel, - "sender_id": sender_id, - "code": code, - }) - ) - - if not deny_code(code): - return self._error_response(404, "Pairing code not found or expired") - return self._json_response( - _pairing_payload({ - "ok": True, - "action": "deny", - "message": f"Denied pairing code {code}", - "code": code, - }) + def _model_operations(self) -> model_domain.ModelSettingsOperations: + return model_domain.ModelSettingsOperations( + update_agent=update_agent_settings, + create_model=create_model_configuration, + update_model=update_model_configuration, + delete_model=delete_model_configuration, + migrate_models=migrate_model_configurations, + update_call_order=update_model_call_order, + update_provider=update_provider_settings, + create_provider=create_provider_settings, + provider_models=provider_models_payload, + oauth_login=login_oauth_provider, + oauth_complete=complete_oauth_provider, + oauth_logout=logout_oauth_provider, + apply_image_runtime_change=self._apply_image_generation_runtime_change_result, ) - def _handle_settings_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate(update_agent_settings, self._query(request)) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload, section="runtime")) - - def _handle_settings_model_configuration_create(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - create_model_configuration, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_model_configuration, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - def _handle_settings_model_configuration_delete(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - delete_model_configuration, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - def _handle_settings_model_configurations_migrate(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - migrate_model_configurations, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - def _handle_settings_model_call_order_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_model_call_order, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - async def _handle_settings_provider_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_provider_settings, - self._parse_provider_settings_query(request) - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - payload = await self._apply_image_generation_runtime_change(payload) - return self._json_response(self._with_restart_state(payload, section="image")) - - def _handle_settings_provider_create(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - create_provider_settings, - self._parse_provider_settings_query(request) - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - async def _handle_settings_provider_models(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = await asyncio.to_thread( - self.settings.read, - provider_models_payload, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - except Exception: - self.logger.exception("failed to load provider model list") - return self._error_response(500, "failed to load provider model list") - return self._json_response(payload) - - async def _handle_settings_provider_oauth( + def _capability_operations( self, - request: WsRequest, - action: str, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - query = self._query(request) - try: - if action == "login": - payload = await asyncio.to_thread( - self.settings.read, - login_oauth_provider, - query, - oauth_flows=self.settings.oauth_flows, - ) - elif action == "complete": - raw_response = (_mutation_payload(request) or {}).get( - "authorization_response" - ) - if raw_response is not None and not isinstance(raw_response, str): - raise WebUISettingsError("OAuth authorization response must be a string") - authorization_response = raw_response - payload = await asyncio.to_thread( - self.settings.read, - complete_oauth_provider, - query, - authorization_response or None, - oauth_flows=self.settings.oauth_flows, - ) - else: - payload = await asyncio.to_thread( - self.settings.read, - logout_oauth_provider, - query, - oauth_flows=self.settings.oauth_flows, - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - if payload.get("status") in {"authorization_required", "pending"}: - return self._json_response(payload) - return self._json_response(self._with_restart_state(payload)) + ) -> capability_domain.CapabilitySettingsOperations: + return capability_domain.CapabilitySettingsOperations( + update_web_search=update_web_search_settings, + update_api=update_api_settings, + update_image=update_image_generation_settings, + update_transcription=update_transcription_settings, + update_network=update_network_safety_settings, + nanobot_features_action=nanobot_features_action, + api_runtime=self._api_runtime, + reload_image=lambda: request_image_generation_reload(self.bus), + ) - def _handle_settings_web_search_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_web_search_settings, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload, section="browser")) + def _system_operations(self) -> system_domain.SystemSettingsOperations: + return system_domain.SystemSettingsOperations( + cli_apps_payload=cli_apps_payload, + cli_apps_action=cli_apps_action, + nanobot_features_payload=nanobot_features_payload, + nanobot_features_action=nanobot_features_action, + nanobot_feature_instance_target=nanobot_feature_instance_target, + validate_channel_config=validate_channel_config, + load_channel_plugin=load_channel_plugin, + list_pending=list_pending, + approve_code=approve_code, + deny_code=deny_code, + mcp_presets_action=mcp_presets_settings_action, + reload_mcp=lambda: request_mcp_reload(self.bus), + check_for_update=check_for_update, + channel_feature_action=self._channel_feature_action, + channel_runtime_status=self._channel_runtime_status, + ) - def _handle_settings_api_service(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - return self._json_response(self._api_service_payload()) - - async def _handle_settings_api_service_start( + async def _apply_image_generation_runtime_change_result( self, - connection: Any, - request: WsRequest, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - await asyncio.to_thread( - self._nanobot_features_action, - "enable", - {"name": ["api"]}, - allow_install=self._allow_feature_package_install(connection, request), - ) - self.settings.mutate( - update_api_settings, - self._parse_api_service_settings_query(request), - ) - config = self.settings.config.load() - runtime = self._api_runtime() - options = ApiStartOptions( - host=config.api.host, - port=config.api.port, - workspace=str(config.workspace_path), - config_path=str(self.settings.config.path), - ) - current = runtime.status() - result = await asyncio.to_thread( - runtime.restart if current.running else runtime.start_background, - options, - ) - if not result.ok: - return self._error_response(500, self._api_runtime_message(result.message)) - except (WebUISettingsError, OptionalFeatureError) as e: - return self._error_response(getattr(e, "status", 400), getattr(e, "message", str(e))) - except Exception as e: - self.logger.exception("failed to start managed API service") - return self._error_response(500, str(e)) - return self._json_response(self._api_service_payload(last_action="started")) + payload: dict[str, Any], + ) -> tuple[dict[str, Any], bool]: + return await self._capabilities.apply_image_runtime_change( + payload, + lambda: request_image_generation_reload(self.bus), + ) + + async def _apply_image_generation_runtime_change( + self, + payload: dict[str, Any], + ) -> dict[str, Any]: + updated, restart_cleared = ( + await self._apply_image_generation_runtime_change_result(payload) + ) + if restart_cleared: + self._restart_sections.discard("image") + return updated + + def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: + return self._query(request) + + def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams: + return self._query(request) def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams: payload = _mutation_payload(request) @@ -630,175 +508,56 @@ class WebUISettingsRouter: raise WebUISettingsError("API service API key must be a string") return self._query(request) - async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - result = await asyncio.to_thread(self._api_runtime().stop) - except Exception as e: - self.logger.exception("failed to stop managed API service") - return self._error_response(500, str(e)) - if not result.ok and result.message != "api_not_running": - return self._error_response(500, self._api_runtime_message(result.message)) - return self._json_response(self._api_service_payload(last_action="stopped")) - def _api_runtime(self) -> ApiRuntime: return ApiRuntime(paths=api_runtime_paths(self.settings.config.path)) - def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]: - config = self.settings.config.load() - status = self._api_runtime().status() - extras = optional_dependency_groups() - connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host - payload = { - "installed": extra_installed("api", extras.get("api")), - "running": status.running, - "managed": status.running, - "host": config.api.host, - "port": config.api.port, - "timeout": config.api.timeout, - "api_key_hint": self._masked_secret(config.api.api_key), - "endpoint": f"http://{connect_host}:{config.api.port}/v1", - "command": "nanobot serve", - "log_path": str(status.log_path), - } - if last_action: - payload["last_action"] = last_action - return payload + def _api_service_payload( + self, + *, + last_action: str | None = None, + ) -> dict[str, Any]: + return capability_domain.api_service_payload( + self.settings, + self._api_runtime(), + last_action=last_action, + ) @staticmethod def _masked_secret(value: str) -> str | None: - value = value.strip() - if not value: - return None - return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured" + return capability_domain.masked_api_secret(value) @staticmethod def _api_runtime_message(message: str) -> str: - known = { - "api_exited_during_startup": "API server exited during startup. Check its log for details.", - "api_stop_timeout": "API server did not stop in time.", - "api_state_stale": "API server state was stale; try starting it again.", - } - if message in known: - return known[message] - if message.startswith("api_"): - return f"API server {message.removeprefix('api_').replace('_', ' ')}" - return message.replace("_", " ") + return capability_domain.api_runtime_message(message) - async def _handle_settings_image_generation_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_image_generation_settings, - self._query(request), + def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]: + return self._system.parse_channel_values( + SettingsRequest( + query=self._query(request), + payload=_mutation_payload(request), ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - payload = await self._apply_image_generation_runtime_change(payload) - return self._json_response(self._with_restart_state(payload, section="image")) + ) - async def _apply_image_generation_runtime_change( + def _save_channel_config_values( self, - payload: dict[str, Any], - ) -> dict[str, Any]: - """Hot-apply image settings, preserving restart fallback on failure.""" - if not payload.get("requires_restart"): - return payload - try: - result = await request_image_generation_reload(self.bus) - except Exception: - self.logger.exception("failed to hot-reload image generation settings") - return payload - - applied = bool(result.get("ok")) and not result.get("requires_restart") - payload = dict(payload) - payload["requires_restart"] = not applied - if applied: - self._restart_sections.discard("image") - else: - self.logger.warning( - "image generation settings were saved but require restart: {}", - result.get("message") or "hot reload failed", + name: str, + raw_values: dict[str, Any], + instance_id: str = "default", + ) -> list[str]: + return self.settings.config.update( + lambda config: system_domain.save_channel_config_values( + config, + name, + raw_values, + instance_id, + load_channel_plugin=load_channel_plugin, ) - return payload + ) - def _handle_settings_transcription_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_transcription_settings, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload)) - - def _handle_settings_network_safety_update(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = self.settings.mutate( - update_network_safety_settings, - self._query(request), - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - return self._json_response(self._with_restart_state(payload, section="runtime")) - - async def _handle_settings_cli_apps(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - installed_only = (_query_first(self._query(request), "installed_only") or "").lower() in { - "1", - "true", - "yes", - } - try: - payload = await cli_apps_payload( - installed_only=installed_only, - config_path=self.settings.config.path, - ) - except Exception: - self.logger.exception("failed to load CLI Apps payload") - return self._error_response(500, "failed to load CLI Apps") - return self._json_response(payload) - - async def _handle_settings_cli_apps_action( - self, - request: WsRequest, - action: str, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = await asyncio.to_thread( - cli_apps_action, - action, - self._query(request), - config_path=self.settings.config.path, - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - except Exception as e: - status = getattr(e, "status", 500) - message = getattr(e, "message", str(e)) - if status >= 500: - self.logger.exception("CLI Apps action '{}' failed", action) - return self._error_response(status, message) - return self._json_response(payload) - - async def _handle_settings_nanobot_features(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = await asyncio.to_thread(self._nanobot_features_payload) - except Exception: - self.logger.exception("failed to load nanobot features") - return self._error_response(500, "failed to load nanobot features") - return self._json_response(self._with_channel_runtime_status(payload)) + _coerce_channel_value = staticmethod(system_domain.coerce_channel_value) + _assign_channel_config_value = staticmethod( + system_domain.assign_channel_config_value + ) def _nanobot_features_payload(self) -> dict[str, Any]: return nanobot_features_payload(config_path=self.settings.config.path) @@ -817,439 +576,28 @@ class WebUISettingsRouter: allow_install=allow_install, ) - async def _handle_settings_nanobot_features_action( - self, - connection: Any, - request: WsRequest, - action: str, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = await asyncio.to_thread( - self._nanobot_features_action, - action, - self._query(request), - allow_install=action != "enable" - or self._allow_feature_package_install(connection, request), - ) - except OptionalFeatureError as e: - return self._error_response(e.status, e.message) - except Exception as e: - status = getattr(e, "status", 500) - message = getattr(e, "message", str(e)) - if status >= 500: - self.logger.exception("nanobot feature action '{}' failed", action) - return self._error_response(status, message) - payload = await self._apply_nanobot_feature_runtime_change( - action, - self._query(request), + @staticmethod + def _feature_runtime_fallback( + payload: dict[str, Any], + *, + message: str, + ) -> dict[str, Any]: + return system_domain.SystemSettingsHandler.feature_runtime_fallback( payload, + message=message, ) - payload = self._with_channel_runtime_status(payload) - return self._json_response(self._with_restart_state(payload, section="runtime")) - def _with_channel_runtime_status(self, payload: dict[str, Any]) -> dict[str, Any]: - if self._channel_runtime_status is None: - return payload - try: - return with_channel_runtime_status(payload, self._channel_runtime_status()) - except Exception: - self.logger.exception("failed to load channel runtime status") - return payload - - async def _apply_nanobot_feature_runtime_change( - self, - action: str, - query: QueryParams, - payload: dict[str, Any], - ) -> dict[str, Any]: - if self._channel_feature_action is None: - return payload - - name = (_query_first(query, "name") or "").strip() - if not name: - return payload - - try: - instance_id = nanobot_feature_instance_target(query) - result = self._channel_feature_action(action, name, instance_id) - if inspect.isawaitable(result): - result = await result - except Exception as exc: - self.logger.exception("failed to apply channel '{}' without restart", name) - return self._feature_runtime_fallback( - payload, - message=f"{name} channel config was saved, but hot reload failed: {exc}", - ) - - if not isinstance(result, dict): - return payload - result = cast(dict[str, Any], result) - if not result.get("handled"): - return payload - - payload = dict(payload) - if result.get("requires_restart"): - payload["requires_restart"] = True - else: - payload["requires_restart"] = False - - message = result.get("message") - if isinstance(message, str) and message: - last_action = dict(payload.get("last_action") or {}) - previous = last_action.get("message") - if isinstance(previous, str) and previous: - last_action["message"] = f"{previous}. {message}" - else: - last_action["message"] = message - last_action["hot_reload"] = not payload["requires_restart"] - if "ok" in result: - last_action["ok"] = bool(result["ok"]) - payload["last_action"] = last_action - return payload - - @staticmethod - def _feature_runtime_fallback(payload: dict[str, Any], *, message: str) -> dict[str, Any]: - payload = dict(payload) - payload["requires_restart"] = True - last_action = dict(payload.get("last_action") or {}) - previous = last_action.get("message") - last_action["message"] = f"{previous}. {message}" if isinstance(previous, str) and previous else message - last_action["hot_reload"] = False - payload["last_action"] = last_action - return payload - - async def _handle_settings_channel_configure( + def _allow_feature_package_install( self, connection: Any, request: WsRequest, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - query = self._query(request) - name = (_query_first(query, "name") or "").strip() - instance_id = (_query_first(query, "instance_id") or "default").strip() - enable = (_query_first(query, "enable") or "").strip().lower() in {"1", "true", "yes"} - try: - saved = await asyncio.to_thread( - self._save_channel_config_values, - name, - self._parse_channel_values(request), - instance_id, - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - except Exception: - self.logger.exception("failed to save channel '{}' settings", name) - return self._error_response(500, "failed to save channel settings") - - payload: dict[str, Any] = { - "name": name, - "saved": True, - "saved_keys": saved, - } - if not enable: - features = await asyncio.to_thread(self._nanobot_features_payload) - features = self._with_channel_runtime_status(features) - payload["nanobot_features"] = self._with_restart_state(features, section="runtime") - return self._json_response(payload) - - feature_query = {"name": [name]} - if instance_id: - feature_query["instance_id"] = [instance_id] - - try: - features = await asyncio.to_thread( - self._nanobot_features_action, - "enable", - feature_query, - allow_install=self._allow_feature_package_install(connection, request), - ) - except OptionalFeatureError as e: - return self._error_response(e.status, f"Settings saved, but {e.message}") - except Exception as e: - self.logger.exception("failed to enable channel '{}' after settings save", name) - return self._error_response(500, f"Settings saved, but enabling {name} failed: {e}") - - features = await self._apply_nanobot_feature_runtime_change( - "enable", - feature_query, - features, + ) -> bool: + domain_request = self._domain_request( + connection, + request, + needs_local_browser=True, ) - features = self._with_channel_runtime_status(features) - payload["nanobot_features"] = self._with_restart_state(features, section="runtime") - return self._json_response(payload) - - async def _handle_settings_channel_validate(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - query = self._query(request) - name = (_query_first(query, "name") or "").strip() - instance_id = (_query_first(query, "instance_id") or "default").strip() - try: - payload = await asyncio.to_thread( - validate_channel_config, - name, - self._parse_channel_values(request), - instance_id=instance_id, - ) - except WebUISettingsError as e: - return self._error_response(e.status, e.message) - except Exception: - self.logger.exception("failed to validate channel '{}' settings", name) - return self._error_response(500, "failed to validate channel settings") - return self._json_response(payload) - - def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]: - payload = _mutation_payload(request) - if payload is None or "values" not in payload: - return {} - values = payload.get("values") - if not isinstance(values, dict): - raise WebUISettingsError("channel settings payload must be a JSON object") - return cast(dict[str, Any], values) - - def _save_channel_config_values( - self, - name: str, - raw_values: dict[str, Any], - instance_id: str = "default", - ) -> list[str]: - if not name: - raise WebUISettingsError("missing channel name") - try: - plugin = load_channel_plugin(name) - except ImportError: - raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None - setup_spec = channel_setup_spec(name, plugin=plugin) - if setup_spec is None: - raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404) - field_types = setup_spec.route_field_types - if not raw_values: - return [] - - def update(config: Config) -> list[str]: - section = getattr(config.channels, name, None) - channel_config = channel_instance_config( - plugin, - section, - instance_id=instance_id, - ) - - saved: list[str] = [] - prefix = f"channels.{name}." - for raw_key, raw_value in raw_values.items(): - if not raw_key: - raise WebUISettingsError( - "channel settings payload contains an invalid key" - ) - field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key - value_type = field_types.get(field) - if value_type is None: - raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI") - value = self._coerce_channel_value(raw_key, raw_value, value_type) - if value is _SKIP_FIELD: - continue - self._assign_channel_config_value(channel_config, field, value) - saved.append(raw_key) - - try: - updated_section = channel_update_instance_config( - plugin, - section, - channel_config, - instance_id=instance_id, - ) - except ValueError as exc: - raise WebUISettingsError( - f"Invalid {name} configuration: {exc}", - status=400, - ) from exc - setattr(config.channels, name, updated_section) - return saved - - return self.settings.config.update(update) - - @staticmethod - def _coerce_channel_value( - raw_key: str, - raw_value: Any, - value_type: RouteFieldType, - ) -> Any: - if isinstance(value_type, tuple): - kind = value_type[0] - allowed = value_type[1] - else: - kind = value_type - allowed = None - - if kind in {"string", "secret"}: - value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value) - if kind == "secret" and not value: - return _SKIP_FIELD - return value - - if kind == "list": - if raw_value is None: - return [] - if isinstance(raw_value, str): - return [item.strip() for item in raw_value.split(",") if item.strip()] - if isinstance(raw_value, list): - return [str(item).strip() for item in cast(list[Any], raw_value) if str(item).strip()] - raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list") - - if kind == "int": - if raw_value in (None, ""): - return _SKIP_FIELD - try: - return int(raw_value) - except (TypeError, ValueError) as exc: - raise WebUISettingsError(f"'{raw_key}' must be a number") from exc - - if kind == "bool": - if isinstance(raw_value, bool): - return raw_value - value = str(raw_value).strip().lower() - if value in {"true", "1", "yes", "on"}: - return True - if value in {"false", "0", "no", "off"}: - return False - raise WebUISettingsError(f"'{raw_key}' must be true or false") - - if kind == "enum": - value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value) - if not value: - return _SKIP_FIELD - if allowed is None or value not in allowed: - options = ", ".join(sorted(allowed or ())) - raise WebUISettingsError(f"'{raw_key}' must be one of: {options}") - return value - - raise WebUISettingsError(f"'{raw_key}' has an unsupported field type") - - @staticmethod - def _assign_channel_config_value(channel_config: dict[str, Any], field: str, value: Any) -> None: - target: dict[str, Any] = channel_config - parts = field.split(".") - for part in parts[:-1]: - current: object = target.get(part) - if not isinstance(current, dict): - current = {} - target[part] = current - target = cast(dict[str, Any], current) - target[parts[-1]] = value - - async def _handle_settings_channel_connect( - self, - connection: Any, - request: WsRequest, - channel_name: str, - action: str, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - - try: - connector = self._channel_connectors.get(channel_name) - if connector is None: - plugin = load_channel_plugin(channel_name) - connector = plugin.load_connector() - self._channel_connectors[channel_name] = connector - except ImportError: - return self._error_response(404, f"channel '{channel_name}' does not support connect") - - try: - payload = await connector.handle(action, self._query(request)) - except ChannelConnectError as exc: - return self._error_response(exc.status, exc.message) - except Exception: - self.logger.exception( - "failed to run {} WebUI connect action for {}", - action, - channel_name, - ) - return self._error_response(500, f"failed to {action} {channel_name} connection") - - if payload.get("status") == "succeeded": - payload = await self._with_channel_connect_success( - connection, - request, - channel_name, - payload, - ) - return self._json_response(payload) - - async def _with_channel_connect_success( - self, - connection: Any, - request: WsRequest, - channel_name: str, - payload: dict[str, Any], - ) -> dict[str, Any]: - target = {"name": [channel_name]} - if payload.get("instance_id"): - target["instance_id"] = [str(payload["instance_id"])] - try: - features = await asyncio.to_thread( - self._nanobot_features_action, - "enable", - target, - allow_install=self._allow_feature_package_install(connection, request), - ) - except OptionalFeatureError as exc: - features = self._feature_runtime_fallback( - self._nanobot_features_payload(), - message=( - f"{channel_name} connected, but enabling channel support failed: " - f"{exc.message}" - ), - ) - else: - features = await self._apply_nanobot_feature_runtime_change( - "enable", - target, - features, - ) - features = self._with_channel_runtime_status(features) - payload = dict(payload) - payload["nanobot_features"] = self._with_restart_state(features, section="runtime") - return payload - - def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool: - if _is_local_browser_request(connection, request.headers): - return True - try: - return bool( - self.settings.config.load().tools.webui_allow_remote_package_install - ) - except Exception: - self.logger.exception("failed to load remote package install policy") - return False - - async def _handle_settings_mcp_presets( - self, - request: WsRequest, - action: str | None = None, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - payload = await mcp_presets_settings_action( - action, - self._parse_mcp_settings_query(request), - reload_mcp=lambda: request_mcp_reload(self.bus), - config=self.settings.config, - ) - except Exception as e: - status = getattr(e, "status", 500) - message = getattr(e, "message", str(e)) - if status >= 500: - self.logger.exception("MCP preset action '{}' failed", action or "list") - return self._error_response(status, message) - if action is None: - return self._json_response(payload) - return self._json_response(self._with_restart_state(payload, section="runtime")) + return self._system.allow_feature_package_install(domain_request) async def _handle_mcp_oauth_start(self, request: WsRequest) -> Response: if not self._authorized(request): @@ -1391,35 +739,3 @@ class WebUISettingsRouter: ), ], ) - - async def _handle_settings_version_check(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - try: - update_info = await asyncio.to_thread(check_for_update) - except Exception: - self.logger.exception("version check failed") - return self._error_response(500, "version check failed") - return self._json_response({ - "updateAvailable": update_info, - }) - - -def _pairing_payload(last_action: dict[str, Any] | None = None) -> dict[str, Any]: - now = time.time() - requests: list[dict[str, Any]] = [] - for item in list_pending(): - expires_at = float(item.get("expires_at", 0) or 0) - created_at = float(item.get("created_at", 0) or 0) - requests.append({ - "code": str(item.get("code", "")), - "channel": str(item.get("channel", "")), - "sender_id": str(item.get("sender_id", "")), - "created_at_ms": int(created_at * 1000) if created_at else None, - "expires_at_ms": int(expires_at * 1000) if expires_at else None, - "expires_in_seconds": max(0, int(expires_at - now)) if expires_at else None, - }) - payload: dict[str, Any] = {"requests": requests} - if last_action is not None: - payload["last_action"] = last_action - return payload diff --git a/nanobot/webui/settings_system.py b/nanobot/webui/settings_system.py new file mode 100644 index 000000000..826ee989f --- /dev/null +++ b/nanobot/webui/settings_system.py @@ -0,0 +1,957 @@ +"""System and channel settings domain logic.""" + +from __future__ import annotations + +import asyncio +import inspect +import re +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypedDict, cast +from zoneinfo import ZoneInfo + +from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.connect import ChannelConnectError +from nanobot.channels.contracts import ( + RouteFieldType, + channel_instance_config, + channel_update_instance_config, +) +from nanobot.config.schema import Config +from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status +from nanobot.security.workspace_access import workspace_sandbox_status +from nanobot.webui.settings_capabilities import network_safety_payload +from nanobot.webui.settings_contracts import ( + QueryParams, + SettingsRequest, + SettingsRouteResult, + WebUISettingsError, + query_first, + query_first_alias, +) +from nanobot.webui.token_usage import token_usage_payload + +if TYPE_CHECKING: + from nanobot.webui.settings_services import WebUISettingsServices + +LoadChannelPlugin = Callable[[str], Any] +ListPendingPairings = Callable[[], Iterable[dict[str, Any]]] +SettingsOperation = Callable[..., Any] + + +@dataclass(frozen=True) +class SystemSettingsOperations: + cli_apps_payload: SettingsOperation + cli_apps_action: SettingsOperation + nanobot_features_payload: SettingsOperation + nanobot_features_action: SettingsOperation + nanobot_feature_instance_target: SettingsOperation + validate_channel_config: SettingsOperation + load_channel_plugin: LoadChannelPlugin + list_pending: ListPendingPairings + approve_code: SettingsOperation + deny_code: SettingsOperation + mcp_presets_action: SettingsOperation + reload_mcp: SettingsOperation + check_for_update: SettingsOperation + channel_feature_action: SettingsOperation | None = None + channel_runtime_status: Callable[[], dict[str, Any]] | None = None + + +class SystemSettingsPayload(TypedDict): + runtime: dict[str, Any] + usage: dict[str, Any] + advanced: dict[str, Any] + version: dict[str, Any] + docs: dict[str, Any] + + +_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$") +_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest" +_SKIP_FIELD = object() + + +def docs_version(version: str) -> str: + """Map package versions to the matching public docs path.""" + normalized = version.strip() + if _DOCS_STABLE_VERSION_RE.fullmatch(normalized): + return normalized + return "latest" + + +def docs_payload(version: str) -> dict[str, Any]: + selected_version = docs_version(version) + base_url = f"https://nanobot.wiki/docs/{selected_version}" + return { + "version": selected_version, + "base_url": base_url, + "chat_apps_url": f"{base_url}/getting-started/chat-apps", + "latest_url": _DOCS_LATEST_URL, + } + + +def system_settings_payload( + config: Config, + *, + config_path: Path, + version: str, +) -> SystemSettingsPayload: + defaults = config.agents.defaults + exec_config = config.tools.exec + sandbox_status = workspace_sandbox_status( + restrict_to_workspace=config.tools.restrict_to_workspace, + workspace=config.workspace_path, + ) + return { + "runtime": { + "config_path": str(config_path.expanduser()), + "workspace_path": str(config.workspace_path), + "gateway_host": config.gateway.host, + "gateway_port": config.gateway.port, + "heartbeat": { + "enabled": config.gateway.heartbeat.enabled, + "interval_s": config.gateway.heartbeat.interval_s, + "keep_recent_messages": config.gateway.heartbeat.keep_recent_messages, + }, + "dream": { + "schedule": defaults.dream.describe_schedule(), + }, + "unified_session": defaults.unified_session, + }, + "usage": token_usage_payload(timezone_name=defaults.timezone), + "advanced": { + "restrict_to_workspace": config.tools.restrict_to_workspace, + "workspace_sandbox": sandbox_status.as_dict(), + **network_safety_payload(config), + "mcp_server_count": len(config.tools.mcp_servers), + "exec_enabled": exec_config.enable, + "exec_sandbox": exec_config.sandbox or None, + "exec_path_prepend_set": bool(exec_config.path_prepend), + "exec_path_append_set": bool(exec_config.path_append), + }, + "version": {"current": version}, + "docs": docs_payload(version), + } + + +def settings_usage_payload(config: Config) -> dict[str, Any]: + """Return the lightweight token usage slice for Overview refreshes.""" + return token_usage_payload(timezone_name=config.agents.defaults.timezone) + + +def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]: + defaults = config.agents.defaults + changed = False + restart_required = False + + timezone = query_first(query, "timezone") + if timezone is not None: + timezone = timezone.strip() + if not timezone: + raise WebUISettingsError("timezone is required") + try: + ZoneInfo(timezone) + except Exception: + raise WebUISettingsError("invalid timezone") from None + timezone_changed = defaults.timezone != timezone + if timezone_changed or defaults.timezone_mode != "manual": + defaults.timezone = timezone + defaults.timezone_mode = "manual" + changed = True + restart_required = timezone_changed + + tool_hint_max_length = query_first_alias( + query, + "tool_hint_max_length", + "toolHintMaxLength", + ) + if tool_hint_max_length is not None: + try: + parsed = int(tool_hint_max_length) + except ValueError: + raise WebUISettingsError( + "tool_hint_max_length must be an integer" + ) from None + if parsed < 20 or parsed > 500: + raise WebUISettingsError( + "tool_hint_max_length must be between 20 and 500" + ) + if defaults.tool_hint_max_length != parsed: + defaults.tool_hint_max_length = parsed + changed = True + restart_required = True + return changed, restart_required + + +def save_channel_config_values( + config: Config, + name: str, + raw_values: dict[str, Any], + instance_id: str = "default", + *, + load_channel_plugin: LoadChannelPlugin, +) -> list[str]: + if not name: + raise WebUISettingsError("missing channel name") + try: + plugin = load_channel_plugin(name) + except ImportError: + raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None + setup_spec = channel_setup_spec(name, plugin=plugin) + if setup_spec is None: + raise WebUISettingsError( + f"channel '{name}' cannot be configured from WebUI", + status=404, + ) + field_types = setup_spec.route_field_types + if not raw_values: + return [] + + section = getattr(config.channels, name, None) + channel_config = channel_instance_config( + plugin, + section, + instance_id=instance_id, + ) + saved: list[str] = [] + prefix = f"channels.{name}." + for raw_key, raw_value in raw_values.items(): + if not raw_key: + raise WebUISettingsError( + "channel settings payload contains an invalid key" + ) + field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key + value_type = field_types.get(field) + if value_type is None: + raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI") + value = coerce_channel_value(raw_key, raw_value, value_type) + if value is _SKIP_FIELD: + continue + assign_channel_config_value(channel_config, field, value) + saved.append(raw_key) + + try: + updated_section = channel_update_instance_config( + plugin, + section, + channel_config, + instance_id=instance_id, + ) + except ValueError as exc: + raise WebUISettingsError( + f"Invalid {name} configuration: {exc}", + status=400, + ) from exc + setattr(config.channels, name, updated_section) + return saved + + +def coerce_channel_value( + raw_key: str, + raw_value: Any, + value_type: RouteFieldType, +) -> Any: + if isinstance(value_type, tuple): + kind = value_type[0] + allowed = value_type[1] + else: + kind = value_type + allowed = None + + if kind in {"string", "secret"}: + value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value) + if kind == "secret" and not value: + return _SKIP_FIELD + return value + + if kind == "list": + if raw_value is None: + return [] + if isinstance(raw_value, str): + return [item.strip() for item in raw_value.split(",") if item.strip()] + if isinstance(raw_value, list): + return [ + str(item).strip() + for item in cast(list[Any], raw_value) + if str(item).strip() + ] + raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list") + + if kind == "int": + if raw_value in (None, ""): + return _SKIP_FIELD + try: + return int(raw_value) + except (TypeError, ValueError) as exc: + raise WebUISettingsError(f"'{raw_key}' must be a number") from exc + + if kind == "bool": + if isinstance(raw_value, bool): + return raw_value + value = str(raw_value).strip().lower() + if value in {"true", "1", "yes", "on"}: + return True + if value in {"false", "0", "no", "off"}: + return False + raise WebUISettingsError(f"'{raw_key}' must be true or false") + + if kind == "enum": + value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value) + if not value: + return _SKIP_FIELD + if allowed is None or value not in allowed: + options = ", ".join(sorted(allowed or ())) + raise WebUISettingsError(f"'{raw_key}' must be one of: {options}") + return value + + raise WebUISettingsError(f"'{raw_key}' has an unsupported field type") + + +def assign_channel_config_value( + channel_config: dict[str, Any], + field: str, + value: Any, +) -> None: + target = channel_config + parts = field.split(".") + for part in parts[:-1]: + current: object = target.get(part) + if not isinstance(current, dict): + current = {} + target[part] = current + target = cast(dict[str, Any], current) + target[parts[-1]] = value + + +def pairing_payload( + list_pending: ListPendingPairings, + last_action: dict[str, Any] | None = None, + *, + now: float | None = None, +) -> dict[str, Any]: + current_time = time.time() if now is None else now + requests: list[dict[str, Any]] = [] + for item in list_pending(): + expires_at = float(item.get("expires_at", 0) or 0) + created_at = float(item.get("created_at", 0) or 0) + requests.append( + { + "code": str(item.get("code", "")), + "channel": str(item.get("channel", "")), + "sender_id": str(item.get("sender_id", "")), + "created_at_ms": int(created_at * 1000) if created_at else None, + "expires_at_ms": int(expires_at * 1000) if expires_at else None, + "expires_in_seconds": ( + max(0, int(expires_at - current_time)) if expires_at else None + ), + } + ) + payload: dict[str, Any] = {"requests": requests} + if last_action is not None: + payload["last_action"] = last_action + return payload + + +class SystemSettingsHandler: + """Handle channel and system commands behind a transport-neutral request DTO.""" + + def __init__(self, settings: WebUISettingsServices, logger: Any) -> None: + self.settings = settings + self.logger = logger + self._channel_connectors: dict[str, Any] = {} + + async def handle( + self, + action: str, + request: SettingsRequest, + operations: SystemSettingsOperations, + *, + channel_name: str | None = None, + connect_action: str | None = None, + ) -> SettingsRouteResult: + if action == "cli-list": + return await self._cli_apps(request, operations) + if action.startswith("cli-"): + return await self._cli_apps_action( + request, + action.removeprefix("cli-"), + operations, + ) + if action == "features-list": + return await self._features(operations) + if action in {"features-enable", "features-disable"}: + return await self._features_action( + request, + action.removeprefix("features-"), + operations, + ) + if action == "channel-validate": + return await self._channel_validate(request, operations) + if action == "channel-configure": + return await self._channel_configure(request, operations) + if action == "channel-connect" and channel_name and connect_action: + return await self._channel_connect( + request, + channel_name, + connect_action, + operations, + ) + if action == "pairing-list": + return SettingsRouteResult.success(pairing_payload(operations.list_pending)) + if action in {"pairing-approve", "pairing-deny"}: + return self._pairing_action( + request, + action.removeprefix("pairing-"), + operations, + ) + if action == "mcp-list": + return await self._mcp_presets(request, None, operations) + if action.startswith("mcp-"): + return await self._mcp_presets( + request, + action.removeprefix("mcp-"), + operations, + ) + if action == "version-check": + return await self._version_check(operations) + return SettingsRouteResult.failure(404, "unknown settings action") + + async def _cli_apps( + self, + request: SettingsRequest, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + installed_only = (query_first(request.query, "installed_only") or "").lower() in { + "1", + "true", + "yes", + } + try: + payload = await operations.cli_apps_payload( + installed_only=installed_only, + config_path=self.settings.config.path, + ) + except Exception: + self.logger.exception("failed to load CLI Apps payload") + return SettingsRouteResult.failure(500, "failed to load CLI Apps") + return SettingsRouteResult.success(payload) + + async def _cli_apps_action( + self, + request: SettingsRequest, + action: str, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + payload = await asyncio.to_thread( + operations.cli_apps_action, + action, + request.query, + config_path=self.settings.config.path, + ) + except WebUISettingsError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + except Exception as exc: + status = getattr(exc, "status", 500) + message = getattr(exc, "message", str(exc)) + if status >= 500: + self.logger.exception("CLI Apps action '{}' failed", action) + return SettingsRouteResult.failure(status, message) + return SettingsRouteResult.success(payload) + + async def _features( + self, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + payload = await asyncio.to_thread( + operations.nanobot_features_payload, + config_path=self.settings.config.path, + ) + except Exception: + self.logger.exception("failed to load nanobot features") + return SettingsRouteResult.failure(500, "failed to load nanobot features") + return SettingsRouteResult.success( + self._with_channel_runtime_status(payload, operations) + ) + + def _nanobot_features_payload( + self, + operations: SystemSettingsOperations, + ) -> dict[str, Any]: + return operations.nanobot_features_payload(config_path=self.settings.config.path) + + def _nanobot_features_action( + self, + action: str, + query: QueryParams, + operations: SystemSettingsOperations, + *, + allow_install: bool = True, + ) -> dict[str, Any]: + return self.settings.mutate( + operations.nanobot_features_action, + action, + query, + allow_install=allow_install, + ) + + async def _features_action( + self, + request: SettingsRequest, + action: str, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + payload = await asyncio.to_thread( + self._nanobot_features_action, + action, + request.query, + operations, + allow_install=( + action != "enable" + or self.allow_feature_package_install(request) + ), + ) + except OptionalFeatureError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + except Exception as exc: + status = getattr(exc, "status", 500) + message = getattr(exc, "message", str(exc)) + if status >= 500: + self.logger.exception( + "nanobot feature action '{}' failed", + action, + ) + return SettingsRouteResult.failure(status, message) + payload = await self._apply_feature_runtime_change( + action, + request.query, + payload, + operations, + ) + payload = self._with_channel_runtime_status(payload, operations) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="runtime", + ) + + def _with_channel_runtime_status( + self, + payload: dict[str, Any], + operations: SystemSettingsOperations, + ) -> dict[str, Any]: + if operations.channel_runtime_status is None: + return payload + try: + return with_channel_runtime_status( + payload, + operations.channel_runtime_status(), + ) + except Exception: + self.logger.exception("failed to load channel runtime status") + return payload + + async def _apply_feature_runtime_change( + self, + action: str, + query: QueryParams, + payload: dict[str, Any], + operations: SystemSettingsOperations, + ) -> dict[str, Any]: + if operations.channel_feature_action is None: + return payload + name = (query_first(query, "name") or "").strip() + if not name: + return payload + try: + instance_id = operations.nanobot_feature_instance_target(query) + result = operations.channel_feature_action(action, name, instance_id) + if inspect.isawaitable(result): + result = await result + except Exception as exc: + self.logger.exception("failed to apply channel '{}' without restart", name) + return self.feature_runtime_fallback( + payload, + message=( + f"{name} channel config was saved, but hot reload failed: {exc}" + ), + ) + + if not isinstance(result, dict): + return payload + result = cast(dict[str, Any], result) + if not result.get("handled"): + return payload + + updated = dict(payload) + updated["requires_restart"] = bool(result.get("requires_restart")) + message = result.get("message") + if isinstance(message, str) and message: + last_action = dict(updated.get("last_action") or {}) + previous = last_action.get("message") + last_action["message"] = ( + f"{previous}. {message}" + if isinstance(previous, str) and previous + else message + ) + last_action["hot_reload"] = not updated["requires_restart"] + if "ok" in result: + last_action["ok"] = bool(result["ok"]) + updated["last_action"] = last_action + return updated + + @staticmethod + def feature_runtime_fallback( + payload: dict[str, Any], + *, + message: str, + ) -> dict[str, Any]: + updated = dict(payload) + updated["requires_restart"] = True + last_action = dict(updated.get("last_action") or {}) + previous = last_action.get("message") + last_action["message"] = ( + f"{previous}. {message}" + if isinstance(previous, str) and previous + else message + ) + last_action["hot_reload"] = False + updated["last_action"] = last_action + return updated + + async def _channel_configure( + self, + request: SettingsRequest, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + name = (query_first(request.query, "name") or "").strip() + instance_id = ( + query_first(request.query, "instance_id") or "default" + ).strip() + enable = (query_first(request.query, "enable") or "").strip().lower() in { + "1", + "true", + "yes", + } + try: + saved = await asyncio.to_thread( + self._save_channel_config_values, + name, + self.parse_channel_values(request), + instance_id, + operations, + ) + except WebUISettingsError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + except Exception: + self.logger.exception("failed to save channel '{}' settings", name) + return SettingsRouteResult.failure(500, "failed to save channel settings") + + payload: dict[str, Any] = { + "name": name, + "saved": True, + "saved_keys": saved, + } + if not enable: + features = await asyncio.to_thread( + self._nanobot_features_payload, + operations, + ) + payload["nanobot_features"] = self._with_channel_runtime_status( + features, + operations, + ) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="runtime", + restart_payload_key="nanobot_features", + ) + + feature_query = {"name": [name]} + if instance_id: + feature_query["instance_id"] = [instance_id] + try: + features = await asyncio.to_thread( + self._nanobot_features_action, + "enable", + feature_query, + operations, + allow_install=self.allow_feature_package_install(request), + ) + except OptionalFeatureError as exc: + return SettingsRouteResult.failure( + exc.status, + f"Settings saved, but {exc.message}", + ) + except Exception as exc: + self.logger.exception( + "failed to enable channel '{}' after settings save", + name, + ) + return SettingsRouteResult.failure( + 500, + f"Settings saved, but enabling {name} failed: {exc}", + ) + + features = await self._apply_feature_runtime_change( + "enable", + feature_query, + features, + operations, + ) + payload["nanobot_features"] = self._with_channel_runtime_status( + features, + operations, + ) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="runtime", + restart_payload_key="nanobot_features", + ) + + async def _channel_validate( + self, + request: SettingsRequest, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + name = (query_first(request.query, "name") or "").strip() + instance_id = ( + query_first(request.query, "instance_id") or "default" + ).strip() + try: + payload = await asyncio.to_thread( + operations.validate_channel_config, + name, + self.parse_channel_values(request), + instance_id=instance_id, + ) + except WebUISettingsError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + except Exception: + self.logger.exception("failed to validate channel '{}' settings", name) + return SettingsRouteResult.failure( + 500, + "failed to validate channel settings", + ) + return SettingsRouteResult.success(payload) + + @staticmethod + def parse_channel_values(request: SettingsRequest) -> dict[str, Any]: + if request.payload is None or "values" not in request.payload: + return {} + values = request.payload.get("values") + if not isinstance(values, dict): + raise WebUISettingsError( + "channel settings payload must be a JSON object" + ) + return cast(dict[str, Any], values) + + def _save_channel_config_values( + self, + name: str, + raw_values: dict[str, Any], + instance_id: str, + operations: SystemSettingsOperations, + ) -> list[str]: + return self.settings.config.update( + lambda config: save_channel_config_values( + config, + name, + raw_values, + instance_id, + load_channel_plugin=operations.load_channel_plugin, + ) + ) + + async def _channel_connect( + self, + request: SettingsRequest, + channel_name: str, + action: str, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + connector = self._channel_connectors.get(channel_name) + if connector is None: + plugin = operations.load_channel_plugin(channel_name) + connector = plugin.load_connector() + self._channel_connectors[channel_name] = connector + except ImportError: + return SettingsRouteResult.failure( + 404, + f"channel '{channel_name}' does not support connect", + ) + + try: + payload = await connector.handle(action, request.query) + except ChannelConnectError as exc: + return SettingsRouteResult.failure(exc.status, exc.message) + except Exception: + self.logger.exception( + "failed to run {} WebUI connect action for {}", + action, + channel_name, + ) + return SettingsRouteResult.failure( + 500, + f"failed to {action} {channel_name} connection", + ) + + if payload.get("status") != "succeeded": + return SettingsRouteResult.success(payload) + payload = await self._with_channel_connect_success( + request, + channel_name, + payload, + operations, + ) + return SettingsRouteResult.success( + payload, + decorate_restart=True, + restart_section="runtime", + restart_payload_key="nanobot_features", + ) + + async def _with_channel_connect_success( + self, + request: SettingsRequest, + channel_name: str, + payload: dict[str, Any], + operations: SystemSettingsOperations, + ) -> dict[str, Any]: + target = {"name": [channel_name]} + if payload.get("instance_id"): + target["instance_id"] = [str(payload["instance_id"])] + try: + features = await asyncio.to_thread( + self._nanobot_features_action, + "enable", + target, + operations, + allow_install=self.allow_feature_package_install(request), + ) + except OptionalFeatureError as exc: + features = self.feature_runtime_fallback( + self._nanobot_features_payload(operations), + message=( + f"{channel_name} connected, but enabling channel support failed: " + f"{exc.message}" + ), + ) + else: + features = await self._apply_feature_runtime_change( + "enable", + target, + features, + operations, + ) + updated = dict(payload) + updated["nanobot_features"] = self._with_channel_runtime_status( + features, + operations, + ) + return updated + + def allow_feature_package_install(self, request: SettingsRequest) -> bool: + if request.local_browser: + return True + try: + return bool( + self.settings.config.load().tools.webui_allow_remote_package_install + ) + except Exception: + self.logger.exception("failed to load remote package install policy") + return False + + def _pairing_action( + self, + request: SettingsRequest, + action: str, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + code = (query_first(request.query, "code") or "").strip() + if not code: + return SettingsRouteResult.failure(400, "Missing pairing code") + if action == "approve": + result = operations.approve_code(code) + if result is None: + return SettingsRouteResult.failure( + 404, + "Pairing code not found or expired", + ) + channel, sender_id = result + return SettingsRouteResult.success( + pairing_payload( + operations.list_pending, + { + "ok": True, + "action": "approve", + "message": f"Approved {sender_id} for {channel}", + "channel": channel, + "sender_id": sender_id, + "code": code, + }, + ) + ) + + if not operations.deny_code(code): + return SettingsRouteResult.failure( + 404, + "Pairing code not found or expired", + ) + return SettingsRouteResult.success( + pairing_payload( + operations.list_pending, + { + "ok": True, + "action": "deny", + "message": f"Denied pairing code {code}", + "code": code, + }, + ) + ) + + async def _mcp_presets( + self, + request: SettingsRequest, + action: str | None, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + payload = await operations.mcp_presets_action( + action, + request.query, + reload_mcp=operations.reload_mcp, + config=self.settings.config, + ) + except Exception as exc: + status = getattr(exc, "status", 500) + message = getattr(exc, "message", str(exc)) + if status >= 500: + self.logger.exception( + "MCP preset action '{}' failed", + action or "list", + ) + return SettingsRouteResult.failure(status, message) + return SettingsRouteResult.success( + payload, + decorate_restart=action is not None, + restart_section="runtime" if action is not None else None, + ) + + async def _version_check( + self, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + try: + update_info = await asyncio.to_thread(operations.check_for_update) + except Exception: + self.logger.exception("version check failed") + return SettingsRouteResult.failure(500, "version check failed") + return SettingsRouteResult.success({"updateAvailable": update_info}) diff --git a/tests/webui/test_settings_capabilities.py b/tests/webui/test_settings_capabilities.py new file mode 100644 index 000000000..be76f8041 --- /dev/null +++ b/tests/webui/test_settings_capabilities.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Any + +from nanobot.config.schema import Config +from nanobot.webui.settings_capabilities import ( + capability_settings_payload, + update_api_settings, + update_image_generation_settings, + update_network_safety_settings, + update_transcription_settings, + update_web_search_settings, +) + + +def _oauth_status(_spec: Any) -> dict[str, Any]: + return {"configured": False} + + +def test_capability_domain_updates_representative_settings() -> None: + config = Config() + config.providers.openrouter.api_key = "sk-test" + + web_changed, web_restart = update_web_search_settings( + config, + { + "provider": ["duckduckgo"], + "max_results": ["7"], + "use_jina_reader": ["false"], + }, + ) + update_api_settings( + config, + {"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]}, + ) + image_changed = update_image_generation_settings( + config, + {"enabled": ["true"], "provider": ["openrouter"]}, + oauth_status=_oauth_status, + ) + transcription_changed = update_transcription_settings( + config, + {"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]}, + ) + network_changed, access_mode = update_network_safety_settings( + config, + { + "webui_allow_local_service_access": ["false"], + "webui_default_access_mode": ["restricted"], + }, + ) + payload = capability_settings_payload(config, oauth_status=_oauth_status) + + assert (web_changed, web_restart) == (True, True) + assert image_changed is True + assert transcription_changed is True + assert (network_changed, access_mode) == (True, "default") + assert payload["web_search"]["max_results"] == 7 + assert payload["api"]["host"] == "127.0.0.2" + assert payload["api"]["port"] == 8900 + assert payload["image_generation"]["enabled"] is True + assert payload["transcription"]["provider"] == "openrouter" diff --git a/tests/webui/test_settings_models.py b/tests/webui/test_settings_models.py new file mode 100644 index 000000000..de6e6c62a --- /dev/null +++ b/tests/webui/test_settings_models.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any + +from nanobot.config.schema import Config +from nanobot.webui.settings_models import ( + model_settings_payload, + update_agent_model_settings, + update_provider_settings, +) + + +def _oauth_status(_spec: Any) -> dict[str, Any]: + return { + "configured": False, + "account": None, + "expires_at": None, + "login_supported": True, + } + + +def test_model_domain_owns_dto_and_config_updates() -> None: + config = Config() + config.providers.openrouter.api_key = "sk-before" + + agent_changed = update_agent_model_settings( + config, + { + "model": ["openai/gpt-5.4"], + "provider": ["openrouter"], + "context_window_tokens": ["200000"], + }, + oauth_status=_oauth_status, + ) + provider_changed, restart_required = update_provider_settings( + config, + { + "provider": ["openrouter"], + "api_key": ["sk-after"], + }, + ) + payload = model_settings_payload(config, oauth_status=_oauth_status) + + assert agent_changed is True + assert provider_changed is True + assert restart_required is False + assert config.agents.defaults.model == "openai/gpt-5.4" + assert config.agents.defaults.provider == "openrouter" + assert config.agents.defaults.context_window_tokens == 200_000 + assert config.providers.openrouter.api_key == "sk-after" + assert set(payload) == { + "agent", + "model_presets", + "model_call_order", + "model_call_order_editable", + "providers", + } + assert payload["agent"]["model"] == "openai/gpt-5.4" diff --git a/tests/webui/test_settings_system.py b/tests/webui/test_settings_system.py new file mode 100644 index 000000000..9362ec929 --- /dev/null +++ b/tests/webui/test_settings_system.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from nanobot.config.schema import Config +from nanobot.webui.settings_system import ( + coerce_channel_value, + system_settings_payload, + update_agent_system_settings, +) + + +def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None: + config = Config() + + changed, restart_required = update_agent_system_settings( + config, + { + "timezone": ["Asia/Shanghai"], + "tool_hint_max_length": ["120"], + }, + ) + payload = system_settings_payload( + config, + config_path=tmp_path / "config.json", + version="0.3.0", + ) + + assert changed is True + assert restart_required is True + assert config.agents.defaults.timezone == "Asia/Shanghai" + assert config.agents.defaults.timezone_mode == "manual" + assert config.agents.defaults.tool_hint_max_length == 120 + assert payload["runtime"]["config_path"] == str(tmp_path / "config.json") + assert payload["version"] == {"current": "0.3.0"} + assert payload["docs"]["version"] == "0.3.0" + assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"} + + +def test_system_domain_validates_channel_field_values() -> None: + assert coerce_channel_value("allow_from", "alice, bob", "list") == [ + "alice", + "bob", + ] + assert coerce_channel_value("enabled", "yes", "bool") is True + assert coerce_channel_value("port", "8765", "int") == 8765