feat(webui): simplify model preset settings (#5061)
This commit is contained in:
+21
-2
@@ -518,10 +518,29 @@ class AgentLoop:
|
||||
self.runtime_resolver.invalidate()
|
||||
self._publish_runtime_selection(self.runtime_resolver.runtime)
|
||||
|
||||
def runtime_for_session(self, session: Session) -> LLMRuntime:
|
||||
def runtime_for_session(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
recover_removed: bool = True,
|
||||
) -> LLMRuntime:
|
||||
"""Resolve the immutable runtime selected by one session."""
|
||||
name = model_preset_from_metadata(session.metadata)
|
||||
return self.llm_runtime() if name is None else self.runtime_resolver.resolve_preset(name)
|
||||
if name is None:
|
||||
return self.llm_runtime()
|
||||
try:
|
||||
return self.runtime_resolver.resolve_preset(name)
|
||||
except KeyError:
|
||||
if not recover_removed or name in self.runtime_resolver.model_presets:
|
||||
raise
|
||||
logger.warning(
|
||||
"Session '{}' references removed model preset '{}'; falling back to default",
|
||||
session.key,
|
||||
name,
|
||||
)
|
||||
session.metadata.pop(SESSION_MODEL_PRESET_METADATA_KEY, None)
|
||||
self.sessions.save(session)
|
||||
return self.llm_runtime()
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
|
||||
@@ -2111,6 +2111,36 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_body["image_generation"]["provider_configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
custom_provider_created = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||
headers={
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": json.dumps(
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
"apiKey": "sk-company",
|
||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert custom_provider_created.status_code == 200
|
||||
custom_provider_body = custom_provider_created.json()
|
||||
custom_provider_name = custom_provider_body["created_provider"]
|
||||
custom_provider_rows = {
|
||||
provider["name"]: provider for provider in custom_provider_body["providers"]
|
||||
}
|
||||
assert custom_provider_rows[custom_provider_name]["label"] == "Company Gateway"
|
||||
assert custom_provider_rows[custom_provider_name]["extra_headers"] == {
|
||||
"X-Tenant": "engineering"
|
||||
}
|
||||
assert "sk-company" not in custom_provider_created.text
|
||||
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
@@ -2160,8 +2190,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
assert created_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
|
||||
assert created_body["created_model_preset"] == "fast-writing"
|
||||
assert created_body["agent"]["model_preset"] == "deep"
|
||||
assert created_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
assert created_body["model_call_order"] == ["deep"]
|
||||
created_presets = {
|
||||
preset["name"]: preset for preset in created_body["model_presets"]
|
||||
}
|
||||
@@ -2176,13 +2208,25 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
assert updated_preset_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert updated_preset_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert updated_preset_body["agent"]["model_preset"] == "deep"
|
||||
assert updated_preset_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
updated_presets = {
|
||||
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
call_order_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-call-order/update"
|
||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert call_order_updated.status_code == 200
|
||||
call_order_body = call_order_updated.json()
|
||||
assert call_order_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
@@ -2269,6 +2313,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||
assert saved.agents.defaults.provider == "atomic_chat"
|
||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||
@@ -2279,6 +2324,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.providers.openrouter.api_key == "sk-or-next"
|
||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
|
||||
custom_provider = saved.providers.model_extra[custom_provider_name]
|
||||
assert custom_provider.display_name == "Company Gateway"
|
||||
assert custom_provider.api_base == "https://gateway.example/v1"
|
||||
assert custom_provider.extra_body == {"service_tier": "priority"}
|
||||
assert saved.tools.web.search.provider == "searxng"
|
||||
assert saved.tools.web.search.api_key == ""
|
||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
||||
|
||||
@@ -323,7 +323,7 @@ def _command_error_message(exc: Exception) -> str:
|
||||
def _model_command_status(loop, session) -> str:
|
||||
names = _model_preset_names(loop)
|
||||
try:
|
||||
runtime = loop.runtime_for_session(session)
|
||||
runtime = loop.runtime_for_session(session, recover_removed=False)
|
||||
except (KeyError, ValueError) as exc:
|
||||
return "\n".join([
|
||||
"## Model",
|
||||
|
||||
@@ -184,6 +184,11 @@ class AgentsConfig(Base):
|
||||
class ProviderConfig(Base):
|
||||
"""LLM provider configuration."""
|
||||
|
||||
# User-facing name for dynamic custom providers.
|
||||
display_name: str | None = Field(
|
||||
default=None,
|
||||
exclude_if=lambda value: value is None,
|
||||
)
|
||||
api_key: str | None = Field(default=None, repr=False)
|
||||
api_base: str | None = None
|
||||
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
|
||||
|
||||
@@ -56,7 +56,11 @@ def _make_provider_core(
|
||||
if provider_name and not spec and p:
|
||||
if not p.api_base:
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
|
||||
spec = create_dynamic_spec(
|
||||
provider_name,
|
||||
display_name=(p.display_name or "") if p else "",
|
||||
thinking_style=(p.thinking_style or "") if p else "",
|
||||
)
|
||||
if spec and spec.is_transcription_only:
|
||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
@@ -239,6 +243,7 @@ def provider_signature(
|
||||
fallback.reasoning_effort,
|
||||
fallback.context_window_tokens,
|
||||
getattr(fp, "proxy", None) if fp else None,
|
||||
fp.thinking_style if fp else None,
|
||||
)
|
||||
|
||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||
@@ -259,6 +264,7 @@ def provider_signature(
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
getattr(p, "proxy", None) if p else None,
|
||||
p.thinking_style if p else None,
|
||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
@@ -729,7 +729,12 @@ def find_by_name(name: str) -> ProviderSpec | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
||||
def create_dynamic_spec(
|
||||
name: str,
|
||||
*,
|
||||
display_name: str = "",
|
||||
thinking_style: str = "",
|
||||
) -> ProviderSpec:
|
||||
"""Create a dynamic ProviderSpec for custom user-defined providers."""
|
||||
normalized = to_snake(name.replace("-", "_"))
|
||||
strip_prefixes = tuple(dict.fromkeys((name, normalized)))
|
||||
@@ -737,7 +742,7 @@ def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
||||
name=normalized,
|
||||
keywords=(),
|
||||
env_key="",
|
||||
display_name=name.title(),
|
||||
display_name=display_name or name.replace("-", " ").replace("_", " ").title(),
|
||||
backend="openai_compat",
|
||||
is_direct=True,
|
||||
strip_model_prefixes=strip_prefixes,
|
||||
|
||||
+555
-61
@@ -6,6 +6,8 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -215,6 +217,164 @@ def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None
|
||||
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 = 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 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):
|
||||
return {
|
||||
key: _redact_provider_secret_values(
|
||||
item,
|
||||
secret=_provider_setting_key_is_secret(key),
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_redact_provider_secret_values(item) for item in 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):
|
||||
current_mapping = 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.items()
|
||||
}
|
||||
if isinstance(submitted, list):
|
||||
current_items = 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)
|
||||
]
|
||||
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 = getattr(exc, "errors", lambda: [])()
|
||||
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
|
||||
@@ -377,19 +537,49 @@ def _resolve_settings_provider(
|
||||
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, thinking_style=(provider_config.thinking_style or "")), extra_name, provider_config
|
||||
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
|
||||
@@ -402,13 +592,19 @@ def _provider_settings_row(
|
||||
"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 in _OAUTH_PROXY_PROVIDERS:
|
||||
row["proxy"] = provider_config.proxy
|
||||
if spec.name == "openai":
|
||||
row["api_type"] = provider_config.api_type
|
||||
return row
|
||||
@@ -675,6 +871,30 @@ def _parse_context_window_tokens(value: str | None) -> int | None:
|
||||
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("-_")
|
||||
@@ -687,6 +907,75 @@ def _model_configuration_slug(label: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _custom_provider_key(config: Any, 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: Any,
|
||||
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: Any, 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: Any) -> 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: Any, provider: str) -> None:
|
||||
if provider == "auto":
|
||||
return
|
||||
@@ -817,7 +1106,11 @@ def settings_payload(
|
||||
providers.append(
|
||||
_provider_settings_row(
|
||||
provider_key,
|
||||
create_dynamic_spec(provider_key, thinking_style=(provider_config.thinking_style or "")),
|
||||
create_dynamic_spec(
|
||||
provider_key,
|
||||
display_name=provider_config.display_name or "",
|
||||
thinking_style=provider_config.thinking_style or "",
|
||||
),
|
||||
provider_config,
|
||||
)
|
||||
)
|
||||
@@ -856,11 +1149,23 @@ def settings_payload(
|
||||
"temperature": defaults.temperature,
|
||||
"reasoning_effort": defaults.reasoning_effort,
|
||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||
defaults.provider, defaults.model
|
||||
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,
|
||||
@@ -869,20 +1174,18 @@ def settings_payload(
|
||||
"is_default": False,
|
||||
"model": preset.model,
|
||||
"provider": preset.provider,
|
||||
"resolved_provider": config.get_provider_name(
|
||||
preset.model,
|
||||
preset=preset,
|
||||
),
|
||||
"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(
|
||||
preset.provider, preset.model
|
||||
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,
|
||||
@@ -905,6 +1208,8 @@ def settings_payload(
|
||||
"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,
|
||||
@@ -1129,19 +1434,38 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider)
|
||||
|
||||
base = config.resolve_default_preset()
|
||||
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=base.max_tokens,
|
||||
context_window_tokens=base.context_window_tokens,
|
||||
temperature=base.temperature,
|
||||
reasoning_effort=base.reasoning_effort,
|
||||
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,
|
||||
)
|
||||
config.agents.defaults.model_preset = name
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
payload = settings_payload()
|
||||
payload["created_model_preset"] = name
|
||||
return payload
|
||||
|
||||
|
||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
@@ -1183,8 +1507,9 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
preset.provider = provider
|
||||
changed = True
|
||||
|
||||
context_window_tokens = _parse_context_window_tokens(
|
||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
||||
context_window_tokens = _parse_positive_int(
|
||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens"),
|
||||
"context_window_tokens",
|
||||
)
|
||||
if (
|
||||
context_window_tokens is not None
|
||||
@@ -1193,15 +1518,193 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
preset.context_window_tokens = context_window_tokens
|
||||
changed = True
|
||||
|
||||
if config.agents.defaults.model_preset != name:
|
||||
config.agents.defaults.model_preset = name
|
||||
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 = 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 order)
|
||||
):
|
||||
raise WebUISettingsError("model call order must contain at least one preset")
|
||||
|
||||
normalized_order = [name.strip() for name in 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 = 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[str] = []
|
||||
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:
|
||||
@@ -1212,52 +1715,43 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
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")
|
||||
if any(
|
||||
key in query
|
||||
for key in ("api_key", "apiKey", "api_base", "apiBase", "api_type")
|
||||
):
|
||||
raise WebUISettingsError("OAuth provider only supports proxy settings")
|
||||
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")
|
||||
|
||||
changed = False
|
||||
if "proxy" in query:
|
||||
proxy = (_query_first(query, "proxy") or "").strip() or None
|
||||
if provider_config.proxy != proxy:
|
||||
provider_config.proxy = proxy
|
||||
changed = True
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
changed = False
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = (api_key or "").strip() or None
|
||||
if provider_config.api_key != api_key:
|
||||
provider_config.api_key = api_key
|
||||
changed = True
|
||||
|
||||
if "api_base" in query or "apiBase" in query:
|
||||
api_base = _query_first_alias(query, "api_base", "apiBase")
|
||||
api_base = (api_base or "").strip() or None
|
||||
if provider_config.api_base != api_base:
|
||||
provider_config.api_base = api_base
|
||||
changed = True
|
||||
|
||||
if "api_type" in query:
|
||||
if spec.name == "openai":
|
||||
api_type = (_query_first(query, "api_type") or "").strip()
|
||||
try:
|
||||
parsed_api_type = type(provider_config)(api_type=api_type).api_type
|
||||
except Exception:
|
||||
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None
|
||||
if provider_config.api_type != parsed_api_type:
|
||||
provider_config.api_type = parsed_api_type
|
||||
changed = True
|
||||
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 = (
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
@@ -51,15 +52,19 @@ from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
complete_oauth_provider,
|
||||
create_model_configuration,
|
||||
create_provider_settings,
|
||||
decorate_settings_payload,
|
||||
delete_model_configuration,
|
||||
login_oauth_provider,
|
||||
logout_oauth_provider,
|
||||
migrate_model_configurations,
|
||||
provider_models_payload,
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_image_generation_settings,
|
||||
update_model_call_order,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
@@ -72,6 +77,8 @@ QueryParams = dict[str, list[str]]
|
||||
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||
@@ -145,8 +152,16 @@ class WebUISettingsRouter:
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
if path == "/api/settings/model-configurations/update":
|
||||
return self._handle_settings_model_configuration_update(request)
|
||||
if path == "/api/settings/model-configurations/delete":
|
||||
return self._handle_settings_model_configuration_delete(request)
|
||||
if path == "/api/settings/model-configurations/migrate":
|
||||
return self._handle_settings_model_configurations_migrate(request)
|
||||
if path == "/api/settings/model-call-order/update":
|
||||
return self._handle_settings_model_call_order_update(request)
|
||||
if path == "/api/settings/provider/update":
|
||||
return await self._handle_settings_provider_update(request)
|
||||
if path == "/api/settings/provider/create":
|
||||
return self._handle_settings_provider_create(request)
|
||||
if path == "/api/settings/provider-models":
|
||||
return await self._handle_settings_provider_models(request)
|
||||
if path == "/api/settings/provider/oauth-login":
|
||||
@@ -269,6 +284,36 @@ class WebUISettingsRouter:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("provider settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
try:
|
||||
payload = json.loads(unquote(raw))
|
||||
except json.JSONDecodeError:
|
||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
elif value is None:
|
||||
text = ""
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
@@ -353,16 +398,52 @@ class WebUISettingsRouter:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configuration_delete(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = delete_model_configuration(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configurations_migrate(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = migrate_model_configurations(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_call_order_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_call_order(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
async def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_provider_settings(self._query(request))
|
||||
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_provider_create(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
|
||||
Reference in New Issue
Block a user