mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 14:58:39 +03:00
1694 lines
59 KiB
Python
1694 lines
59 KiB
Python
"""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)
|