nanobot/nanobot/webui/settings_api.py
2026-08-03 14:06:44 +08:00

2351 lines
84 KiB
Python

"""Settings REST helpers for the WebUI HTTP surface.
The WebSocket channel owns transport/authentication. This module owns the
settings payload shape and the allowlisted config mutations exposed to WebUI.
"""
# oauth-cli-kit is an optional dependency and does not publish type stubs.
# pyright: reportMissingTypeStubs=false
from __future__ import annotations
import json
import math
import os
import re
import secrets
import threading
import time
from collections.abc import Iterable
from contextlib import suppress
from typing import 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.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"]
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,
}
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
"can_open_logs": False,
"can_export_diagnostics": False,
}
_NATIVE_RUNTIME_CAPABILITIES = {
**_RUNTIME_CAPABILITIES,
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
}
_BROWSER_RESTART_BEHAVIOR_BY_SECTION = {
"appearance": "none",
"models": "none",
"providers": "none",
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"apps": "engineRestart",
"advanced": "appRestart",
}
_NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
**_BROWSER_RESTART_BEHAVIOR_BY_SECTION,
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"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
_WEBUI_OAUTH_MAX_FLOWS = 8
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
_webui_oauth_flows_lock = threading.Lock()
_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 _normalize_surface(surface: str | None) -> RuntimeSurface:
return "native" if surface in {"native", "desktop"} else "browser"
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"
else _RUNTIME_CAPABILITIES
)
result = dict(base)
for key, value in (overrides or {}).items():
if key in result:
result[key] = bool(value)
return result
def restart_behavior_by_section(surface: str | None = "browser") -> dict[str, str]:
return dict(
_NATIVE_RESTART_BEHAVIOR_BY_SECTION
if _normalize_surface(surface) == "native"
else _BROWSER_RESTART_BEHAVIOR_BY_SECTION
)
def decorate_settings_payload(
payload: dict[str, Any],
*,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Attach runtime-surface metadata without changing the core settings shape."""
surface_value = _normalize_surface(surface)
sections = restart_required_sections
if sections is None:
raw_sections: object = payload.get("restart_required_sections") or []
sections = [
section
for section in cast(Iterable[object], raw_sections)
if isinstance(section, str)
]
sections = sorted(dict.fromkeys(sections))
result = dict(payload)
result["surface"] = surface_value
result["runtime_surface"] = surface_value
result["runtime_capabilities"] = runtime_capabilities(
surface_value,
runtime_capability_overrides,
)
result["restart_behavior_by_section"] = restart_behavior_by_section(surface_value)
result["restart_required_sections"] = sections
if sections:
result["requires_restart"] = True
else:
result["requires_restart"] = bool(result.get("requires_restart", False))
result["apply_state"] = apply_state or {
"status": "pending" if result["requires_restart"] else "idle",
"sections": sections,
}
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) -> 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_config()
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,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
config = load_config()
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
),
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(get_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,
surface=surface,
runtime_capability_overrides=runtime_capability_overrides,
restart_required_sections=restart_required_sections,
apply_state=apply_state,
)
def settings_usage_payload() -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
config = load_config()
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
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
if defaults.timezone != timezone:
defaults.timezone = timezone
changed = True
restart_required = True
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
if changed:
save_config(config)
return settings_payload(requires_restart=restart_required)
def create_model_configuration(query: QueryParams) -> 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_config()
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,
)
save_config(config)
payload = settings_payload()
payload["created_model_preset"] = name
return payload
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name or name == "default":
raise WebUISettingsError("model configuration is required")
config = load_config()
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
):
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_config(config)
return settings_payload()
def update_model_call_order(query: QueryParams) -> 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_config()
_, 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
save_config(config)
return settings_payload()
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
"""Materialize legacy primary/inline model settings as named presets."""
config = load_config()
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
save_config(config)
return settings_payload()
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name or name == "default":
raise WebUISettingsError("model configuration is required")
config = load_config()
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]
save_config(config)
return settings_payload()
def create_provider_settings(query: QueryParams) -> 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_config()
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)
save_config(config)
payload = settings_payload()
payload["created_provider"] = provider_key
return payload
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
config = load_config()
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
if changed:
setattr(config.providers, provider_key, updated_provider_config)
save_config(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 settings_payload(requires_restart=restart_required)
def login_oauth_provider(query: QueryParams) -> 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 not installed. Run: pip install oauth-cli-kit", status=500
) from None
try:
proxy = resolve_config_env_vars(load_config()).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)
_register_webui_oauth_flow(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 not installed. Run: pip install oauth-cli-kit", 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()
if spec.name == "xai_grok":
from nanobot.providers.xai_oauth import start_xai_oauth_login
try:
proxy = resolve_config_env_vars(load_config()).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)
_register_webui_oauth_flow(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,
) -> 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 = _get_webui_oauth_flow(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:
_remove_webui_oauth_flow(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,
}
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
if not token.access:
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
def logout_oauth_provider(query: QueryParams) -> 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 not installed. Run: pip install oauth-cli-kit", status=500
) from None
_clear_webui_oauth_flows(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 not installed. Run: pip install oauth-cli-kit", 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
_clear_webui_oauth_flows(spec.name)
logout_xai_oauth()
return settings_payload()
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()
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
discarded: list[Any] = []
with _webui_oauth_flows_lock:
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
if existing.expired:
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
oldest_id = next(iter(_webui_oauth_flows))
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
_webui_oauth_flows[flow_id] = (provider_name, flow)
for existing in discarded:
existing.cancel()
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if registered is None or registered[0] != provider_name:
return None
flow = registered[1]
if not flow.expired:
return flow
_webui_oauth_flows.pop(flow_id, None)
flow.cancel()
return None
def _remove_webui_oauth_flow(
provider_name: str,
flow_id: str,
flow: Any,
*,
cancel: bool = True,
) -> None:
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if (
registered is not None
and registered[0] == provider_name
and registered[1] is flow
):
_webui_oauth_flows.pop(flow_id)
if cancel:
flow.cancel()
def _clear_webui_oauth_flows(provider_name: str) -> None:
with _webui_oauth_flows_lock:
flow_ids = [
flow_id
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
if registered_provider == provider_name
]
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
for flow in flows:
flow.cancel()
def update_network_safety_settings(query: QueryParams) -> 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_config()
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
if changed:
save_config(config)
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")
try:
write_webui_default_access_mode(default_access_mode)
except ValueError as exc:
raise WebUISettingsError(str(exc)) from exc
return settings_payload(requires_restart=changed)
def update_web_search_settings(query: QueryParams) -> 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_config()
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
if changed:
save_config(config)
return settings_payload(requires_restart=restart_required)
def update_api_settings(query: QueryParams) -> dict[str, Any]:
"""Update the managed OpenAI-compatible API configuration."""
config = load_config()
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")
save_config(config)
return settings_payload()
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
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)
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_config(config)
return settings_payload(requires_restart=changed)
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
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:
save_config(config)
return settings_payload()