feat(webui): simplify model preset settings (#5061)

This commit is contained in:
chengyongru
2026-07-24 00:55:06 +08:00
committed by GitHub
parent d993c81f08
commit aae259c790
29 changed files with 4548 additions and 1182 deletions
+21 -2
View File
@@ -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"
+1 -1
View File
@@ -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",
+5
View File
@@ -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
+7 -1
View File
@@ -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),
)
+7 -2
View File
@@ -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
View File
@@ -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 = (
+82 -1
View File
@@ -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()