diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 50df4949..fba15e00 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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, diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 934bc5a6..fcefdb23 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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" diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 11d17ef3..a9289001 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -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", diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 47c04ae2..fef6c261 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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 diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 7fdec1a3..8b2cb83e 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -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), ) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d5acd0de..a4409f42 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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, diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 0ba46362..85c52f6c 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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 = ( diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 19a4fcff..4ff4f4fb 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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() diff --git a/tests/agent/test_session_model_runtime.py b/tests/agent/test_session_model_runtime.py index aff77abe..c59457f9 100644 --- a/tests/agent/test_session_model_runtime.py +++ b/tests/agent/test_session_model_runtime.py @@ -106,6 +106,32 @@ async def test_sessions_run_concurrently_with_isolated_model_presets(tmp_path) - assert load_counts == {"fast": 1, "deep": 1} +@pytest.mark.asyncio +async def test_removed_session_model_preset_falls_back_and_clears_metadata(tmp_path) -> None: + base = RecordingProvider("base-model") + loop = AgentLoop( + bus=MessageBus(), + provider=base, + workspace=tmp_path, + model="base-model", + context_window_tokens=8_000, + ) + loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign] + session_key = "sdk:removed-preset" + session = loop.sessions.get_or_create(session_key) + session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "removed" + loop.sessions.save(session) + + reply = await loop.process_direct("hello", session_key=session_key) + + assert reply is not None + assert reply.content == "reply from base-model" + assert base.calls == ["base-model"] + loop.sessions.invalidate(session_key) + restored = loop.sessions.get_or_create(session_key) + assert model_preset_from_metadata(restored.metadata) is None + + @pytest.mark.asyncio async def test_streamed_sdk_resolves_session_runtime_after_lock_admission(tmp_path) -> None: base = RecordingProvider("base-model") diff --git a/tests/providers/test_custom_thinking_style.py b/tests/providers/test_custom_thinking_style.py index 44cdf17b..a539baee 100644 --- a/tests/providers/test_custom_thinking_style.py +++ b/tests/providers/test_custom_thinking_style.py @@ -2,7 +2,8 @@ from __future__ import annotations -from nanobot.config.schema import ProviderConfig, ProvidersConfig +from nanobot.config.schema import Config, ProviderConfig, ProvidersConfig +from nanobot.providers.factory import provider_signature from nanobot.providers.registry import create_dynamic_spec @@ -60,3 +61,50 @@ class TestCustomProviderThinkingStyle: assert "thinking_type" in message assert "enable_thinking" in message assert "reasoning_split" in message + + def test_provider_signature_tracks_dynamic_primary_thinking_style(self) -> None: + before = Config.model_validate( + { + "agents": {"defaults": {"modelPreset": "primary"}}, + "modelPresets": { + "primary": {"model": "tenant-model", "provider": "tenant"}, + }, + "providers": { + "tenant": { + "apiBase": "https://example.com/v1", + "thinkingStyle": "thinking_type", + }, + }, + } + ) + after = before.model_copy(deep=True) + after.providers.model_extra["tenant"].thinking_style = "enable_thinking" + + assert provider_signature(before) != provider_signature(after) + + def test_provider_signature_tracks_dynamic_fallback_thinking_style(self) -> None: + before = Config.model_validate( + { + "agents": { + "defaults": { + "modelPreset": "primary", + "fallbackModels": ["fallback"], + } + }, + "modelPresets": { + "primary": {"model": "openai/gpt-4.1", "provider": "openai"}, + "fallback": {"model": "tenant-model", "provider": "tenant"}, + }, + "providers": { + "openai": {"apiKey": "sk-openai"}, + "tenant": { + "apiBase": "https://example.com/v1", + "thinkingStyle": "thinking_type", + }, + }, + } + ) + after = before.model_copy(deep=True) + after.providers.model_extra["tenant"].thinking_style = "enable_thinking" + + assert provider_signature(before) != provider_signature(after) diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 418d4a4e..7cd2cce9 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -8,7 +8,7 @@ import httpx import pytest from nanobot.config.loader import load_config, save_config -from nanobot.config.schema import Config, ModelPresetConfig +from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig from nanobot.providers.registry import find_by_name from nanobot.webui.settings_api import ( WebUISettingsError, @@ -18,13 +18,17 @@ from nanobot.webui.settings_api import ( _reasoning_effort_values_for, complete_oauth_provider, create_model_configuration, + create_provider_settings, + 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_model_call_order, update_model_configuration, update_network_safety_settings, update_provider_settings, @@ -186,7 +190,7 @@ def _dynamic_provider_config( return Config.model_validate(raw_config) -def test_create_model_configuration_writes_label_and_selects( +def test_create_model_configuration_writes_label_without_changing_call_order( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -206,13 +210,14 @@ def test_create_model_configuration_writes_label_and_selects( } ) - assert payload["agent"]["model_preset"] == "fast-writing" - assert payload["agent"]["model"] == "openai/gpt-4.1-mini" + assert payload["agent"]["model_preset"] == "default" + assert payload["agent"]["model"] == "openai/gpt-4o" + assert payload["created_model_preset"] == "fast-writing" rows = {row["name"]: row for row in payload["model_presets"]} assert rows["fast-writing"]["label"] == "Fast writing" saved = load_config(config_path) - assert saved.agents.defaults.model_preset == "fast-writing" + assert saved.agents.defaults.model_preset is None assert saved.model_presets["fast-writing"].label == "Fast writing" assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini" assert saved.model_presets["fast-writing"].provider == "openai" @@ -244,8 +249,8 @@ def test_create_model_configuration_accepts_dynamic_custom_provider( } ) - assert payload["agent"]["model_preset"] == "tenant-model" - assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME + assert payload["agent"]["model_preset"] == "default" + assert payload["created_model_preset"] == "tenant-model" saved = load_config(config_path) assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME assert saved.model_presets["tenant-model"].model == "gpt-4o-mini" @@ -294,7 +299,7 @@ def test_create_model_configuration_rejects_unconfigured_provider( ) -def test_update_model_configuration_edits_named_preset_and_selects( +def test_update_model_configuration_edits_named_preset_without_selecting( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -327,15 +332,178 @@ def test_update_model_configuration_edits_named_preset_and_selects( } ) - assert payload["agent"]["model_preset"] == "codex" - assert payload["agent"]["model"] == "openai-codex/gpt-5.5" + assert payload["agent"]["model_preset"] == "default" + assert payload["agent"]["model"] == "anthropic/claude-opus-4-5" saved = load_config(config_path) - assert saved.agents.defaults.model_preset == "codex" + assert saved.agents.defaults.model_preset is None assert saved.model_presets["codex"].label == "Codex" assert saved.model_presets["codex"].provider == "openai_codex" assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5" +def test_settings_payload_exposes_named_model_call_order( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets = { + "primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"), + "backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"), + } + config.agents.defaults.model_preset = "primary" + config.agents.defaults.fallback_models = ["backup", "backup"] + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = settings_payload() + + assert payload["model_call_order"] == ["primary", "backup", "backup"] + assert payload["model_call_order_editable"] is True + + +def test_update_model_call_order_sets_primary_and_fallbacks( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets = { + "primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"), + "backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"), + } + config.agents.defaults.model_preset = "primary" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = update_model_call_order({"order": [json.dumps(["backup", "primary"])]}) + + assert payload["model_call_order"] == ["backup", "primary"] + saved = load_config(config_path) + assert saved.agents.defaults.model_preset == "backup" + assert saved.agents.defaults.fallback_models == ["primary"] + + +def test_update_model_call_order_requires_named_primary( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets["backup"] = ModelPresetConfig(model="openai/gpt-4.1-mini") + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + with pytest.raises(WebUISettingsError) as error: + update_model_call_order({"order": [json.dumps(["backup"])]}) + + assert error.value.status == 409 + assert load_config(config_path).agents.defaults.model_preset is None + + +def test_migrate_model_configurations_preserves_legacy_chain( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.agents.defaults.model = "openai/gpt-4o" + config.agents.defaults.provider = "openai" + config.agents.defaults.max_tokens = 4096 + config.agents.defaults.temperature = 0.25 + config.agents.defaults.fallback_models = [ + InlineFallbackConfig( + model="anthropic/claude-sonnet-4", + provider="anthropic", + ) + ] + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + legacy_payload = settings_payload() + assert legacy_payload["model_call_order"] == [] + assert legacy_payload["model_call_order_editable"] is False + + payload = migrate_model_configurations() + + assert payload["model_call_order_editable"] is True + assert payload["model_call_order"] == ["gpt-4o", "claude-sonnet-4"] + saved = load_config(config_path) + assert saved.agents.defaults.model_preset == "gpt-4o" + assert saved.agents.defaults.fallback_models == ["claude-sonnet-4"] + assert saved.model_presets["gpt-4o"].temperature == 0.25 + assert saved.model_presets["claude-sonnet-4"].max_tokens == 4096 + assert saved.model_presets["claude-sonnet-4"].temperature == 0.25 + + repeated = migrate_model_configurations() + assert repeated["model_call_order"] == ["gpt-4o", "claude-sonnet-4"] + assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"} + + +def test_model_configuration_advanced_options_round_trip( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.openai.api_key = "sk-test" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + created = create_model_configuration( + { + "label": ["Reasoning"], + "provider": ["openai"], + "model": ["openai/o3"], + "max_tokens": ["16384"], + "context_window_tokens": ["262144"], + "temperature": ["0.4"], + "reasoning_effort": ["high"], + } + ) + row = next(row for row in created["model_presets"] if row["name"] == "reasoning") + assert row["max_tokens"] == 16384 + assert row["context_window_tokens"] == 262144 + assert row["temperature"] == 0.4 + assert row["reasoning_effort"] == "high" + + updated = update_model_configuration( + { + "name": ["reasoning"], + "max_tokens": ["8192"], + "temperature": ["0"], + "reasoning_effort": [""], + } + ) + row = next(row for row in updated["model_presets"] if row["name"] == "reasoning") + assert row["max_tokens"] == 8192 + assert row["temperature"] == 0 + assert row["reasoning_effort"] is None + + +def test_delete_model_configuration_requires_removing_it_from_call_order( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.model_presets = { + "primary": ModelPresetConfig(model="openai/gpt-4.1"), + "spare": ModelPresetConfig(model="openai/gpt-4.1-mini"), + } + config.agents.defaults.model_preset = "primary" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + with pytest.raises(WebUISettingsError) as referenced: + delete_model_configuration({"name": ["primary"]}) + assert referenced.value.status == 409 + + payload = delete_model_configuration({"name": ["spare"]}) + assert {row["name"] for row in payload["model_presets"]} == {"default", "primary"} + assert "spare" not in load_config(config_path).model_presets + + def test_update_provider_settings_updates_dynamic_custom_provider( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -361,6 +529,161 @@ def test_update_provider_settings_updates_dynamic_custom_provider( assert dynamic_provider.api_key == "sk-test" +def test_create_provider_settings_persists_custom_advanced_options( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = create_provider_settings( + { + "name": ["Company Gateway"], + "apiBase": ["https://gateway.example/v1"], + "apiKey": ["sk-company"], + "proxy": ["http://127.0.0.1:7890"], + "extraHeaders": [json.dumps({"X-Tenant": "engineering"})], + "extraBody": [json.dumps({"service_tier": "priority"})], + "extraQuery": [json.dumps({"api-version": "2026-01-01"})], + "thinkingStyle": ["enable_thinking"], + } + ) + + provider_name = payload["created_provider"] + row = next(provider for provider in payload["providers"] if provider["name"] == provider_name) + assert row["label"] == "Company Gateway" + assert row["is_custom"] is True + assert row["advanced_fields"] == [ + "extra_headers", + "extra_body", + "extra_query", + "proxy", + "thinking_style", + ] + assert row["extra_headers"] == {"X-Tenant": "engineering"} + assert row["extra_body"] == {"service_tier": "priority"} + assert row["extra_query"] == {"api-version": "2026-01-01"} + + saved = load_config(config_path).providers.model_extra[provider_name] + assert saved.display_name == "Company Gateway" + assert saved.api_key == "sk-company" + assert saved.api_base == "https://gateway.example/v1" + assert saved.proxy == "http://127.0.0.1:7890" + assert saved.extra_headers == {"X-Tenant": "engineering"} + assert saved.extra_body == {"service_tier": "priority"} + assert saved.extra_query == {"api-version": "2026-01-01"} + assert saved.thinking_style == "enable_thinking" + + +def test_provider_settings_redacts_and_preserves_structured_secrets( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.openai.api_key = "sk-openai" + config.providers.openai.extra_headers = { + "Authorization": "Bearer header-secret", + "X-Trace": "visible", + } + config.providers.openai.extra_body = { + "access_token": "body-secret", + "metadata": { + "client_secret": "nested-secret", + "label": "visible", + }, + } + config.providers.openai.extra_query = { + "api_key": "query-secret", + "api-version": "2026-01-01", + } + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = settings_payload() + row = next(provider for provider in payload["providers"] if provider["name"] == "openai") + serialized_row = json.dumps(row, ensure_ascii=False) + + assert "header-secret" not in serialized_row + assert "body-secret" not in serialized_row + assert "nested-secret" not in serialized_row + assert "query-secret" not in serialized_row + assert row["extra_headers"]["X-Trace"] == "visible" + assert row["extra_body"]["metadata"]["label"] == "visible" + assert row["extra_query"]["api-version"] == "2026-01-01" + + row["extra_headers"]["X-Trace"] = "updated" + row["extra_body"]["access_token"] = "replacement-secret" + row["extra_body"]["metadata"]["label"] = "updated" + row["extra_query"]["api-version"] = "2026-07-24" + update_provider_settings( + { + "provider": ["openai"], + "extraHeaders": [json.dumps(row["extra_headers"], ensure_ascii=False)], + "extraBody": [json.dumps(row["extra_body"], ensure_ascii=False)], + "extraQuery": [json.dumps(row["extra_query"], ensure_ascii=False)], + } + ) + + saved = load_config(config_path).providers.openai + assert saved.extra_headers == { + "Authorization": "Bearer header-secret", + "X-Trace": "updated", + } + assert saved.extra_body == { + "access_token": "replacement-secret", + "metadata": { + "client_secret": "nested-secret", + "label": "updated", + }, + } + assert saved.extra_query == { + "api_key": "query-secret", + "api-version": "2026-07-24", + } + + +def test_update_provider_settings_persists_provider_specific_advanced_options( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.openai.api_key = "sk-openai" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + update_provider_settings( + { + "provider": ["openai"], + "apiType": ["responses"], + "proxy": ["http://127.0.0.1:7890"], + "extraHeaders": [json.dumps({"X-Trace": "enabled"})], + "extraBody": [json.dumps({"service_tier": "priority"})], + "extraQuery": [json.dumps({"trace": "true"})], + } + ) + update_provider_settings( + { + "provider": ["bedrock"], + "region": ["us-west-2"], + "profile": ["production"], + "extraBody": [json.dumps({"guardrailIdentifier": "guardrail-1"})], + } + ) + + saved = load_config(config_path) + assert saved.providers.openai.api_type == "responses" + assert saved.providers.openai.proxy == "http://127.0.0.1:7890" + assert saved.providers.openai.extra_headers == {"X-Trace": "enabled"} + assert saved.providers.openai.extra_body == {"service_tier": "priority"} + assert saved.providers.openai.extra_query == {"trace": "true"} + assert saved.providers.bedrock.region == "us-west-2" + assert saved.providers.bedrock.profile == "production" + assert saved.providers.bedrock.extra_body == {"guardrailIdentifier": "guardrail-1"} + + @pytest.mark.parametrize( ("provider_name", "config_attr"), [ @@ -414,7 +737,7 @@ def test_update_provider_settings_keeps_oauth_credentials_read_only( save_config(Config(), config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - with pytest.raises(WebUISettingsError, match="only supports proxy settings"): + with pytest.raises(WebUISettingsError, match="only supports proxy and extra_body settings"): update_provider_settings({"provider": ["openai_codex"], "apiKey": ["not-allowed"]}) @@ -434,7 +757,7 @@ def test_update_agent_settings_accepts_context_window_options( assert saved.agents.defaults.context_window_tokens == 200000 -def test_update_model_configuration_accepts_context_window_options( +def test_update_model_configuration_preserves_custom_context_windows( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -451,13 +774,14 @@ def test_update_model_configuration_accepts_context_window_options( payload = update_model_configuration( { "name": ["codex"], - "context_window_tokens": ["1048576"], + "context_window_tokens": ["128000"], } ) - assert payload["agent"]["context_window_tokens"] == 1048576 + rows = {row["name"]: row for row in payload["model_presets"]} + assert rows["codex"]["context_window_tokens"] == 128000 saved = load_config(config_path) - assert saved.model_presets["codex"].context_window_tokens == 1048576 + assert saved.model_presets["codex"].context_window_tokens == 128000 def test_update_context_window_rejects_unknown_values( @@ -1490,7 +1814,8 @@ def test_create_model_configuration_accepts_configured_oauth_provider( } ) - assert payload["agent"]["model_preset"] == "codex" + assert payload["agent"]["model_preset"] == "default" + assert payload["created_model_preset"] == "codex" saved = load_config(config_path) assert saved.model_presets["codex"].provider == "openai_codex" @@ -1577,7 +1902,8 @@ def test_create_model_configuration_accepts_azure_openai_aad_mode( } ) - assert payload["agent"]["model_preset"] == "azure-aad" + assert payload["agent"]["model_preset"] == "default" + assert payload["created_model_preset"] == "azure-aad" saved = load_config(config_path) assert saved.model_presets["azure-aad"].provider == "azure_openai" assert saved.model_presets["azure-aad"].model == "my-deployment" diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index 9a87abc3..b0ad116b 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -11,6 +11,22 @@ from nanobot.webui.http_utils import http_json_response from nanobot.webui.settings_routes import WebUISettingsRouter +def _router(*, authorized: bool = True) -> WebUISettingsRouter: + return WebUISettingsRouter( + bus=SimpleNamespace(), + logger=SimpleNamespace(exception=lambda *_args: None), + check_api_token=lambda _request: authorized, + parse_query=lambda path: parse_qs(urlsplit(path).query), + json_response=http_json_response, + error_response=lambda status, message: http_json_response( + {"error": message}, + status=status, + ), + runtime_surface="browser", + runtime_capabilities={}, + ) + + @pytest.mark.asyncio async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) -> None: captured: dict[str, object] = {} @@ -24,19 +40,7 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) } monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete) - router = WebUISettingsRouter( - bus=SimpleNamespace(), - logger=SimpleNamespace(exception=lambda *_args: None), - check_api_token=lambda _request: True, - parse_query=lambda path: parse_qs(urlsplit(path).query), - json_response=http_json_response, - error_response=lambda status, message: http_json_response( - {"error": message}, - status=status, - ), - runtime_surface="browser", - runtime_capabilities={}, - ) + router = _router() request = SimpleNamespace( path=( "/api/settings/provider/oauth-login/complete" @@ -70,3 +74,51 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) "authorization_code": "secret", } assert "secret" not in request.path + + +@pytest.mark.parametrize( + ("request_path", "route_path", "function_name", "expected_query"), + [ + ( + "/api/settings/model-configurations/delete?name=spare", + "/api/settings/model-configurations/delete", + "delete_model_configuration", + {"name": ["spare"]}, + ), + ( + "/api/settings/model-configurations/migrate", + "/api/settings/model-configurations/migrate", + "migrate_model_configurations", + {}, + ), + ( + "/api/settings/model-call-order/update?order=%5B%22backup%22%5D", + "/api/settings/model-call-order/update", + "update_model_call_order", + {"order": ['["backup"]']}, + ), + ], +) +@pytest.mark.asyncio +async def test_model_preset_mutation_routes( + monkeypatch, + request_path: str, + route_path: str, + function_name: str, + expected_query: dict[str, list[str]], +) -> None: + captured: dict[str, object] = {} + + def mutate(query): + captured["query"] = query + return {"routed": function_name} + + monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate) + request = SimpleNamespace(path=request_path, headers=Headers()) + + response = await _router().dispatch(None, request, route_path) + + assert response is not None + assert response.status_code == 200 + assert json.loads(response.body)["routed"] == function_name + assert captured["query"] == expected_query diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index ab863191..f30cce44 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -30,11 +30,13 @@ import { ExternalLink, Gem, Globe2, + GripVertical, Grid3X3, HardDrive, Hexagon, ImageIcon, Layers, + ListOrdered, Loader2, LogOut, MessageCircle, @@ -100,6 +102,8 @@ import { checkVersion, completeProviderOAuth, createModelConfiguration, + createProviderSettings, + deleteModelConfiguration, disableNanobotFeature, enableNanobotFeature, fetchApiService, @@ -113,6 +117,7 @@ import { importMcpConfig, loginProviderOAuth, logoutProviderOAuth, + migrateModelConfigurations, runAutomationAction, runCliAppAction, runMcpPresetAction, @@ -122,6 +127,7 @@ import { updateAutomation, updateImageGenerationSettings, updateMcpServerTools, + updateModelCallOrder, updateModelConfiguration, updateNetworkSafetySettings, updateProviderSettings, @@ -171,6 +177,7 @@ import type { ProviderOAuthCompletionResult, ProviderOAuthLoginResult, ProviderOAuthPending, + ProviderSettingsUpdate, SessionAutomationJob, SettingsPayload, SkillSummary, @@ -218,19 +225,16 @@ interface AgentSettingsDraft { provider: string; modelPreset: string; presetLabel: string; + maxTokens: number; contextWindowTokens: number; + temperature: number; + reasoningEffort: string; timezone: string; botName: string; botIcon: string; toolHintMaxLength: number; } -interface ModelConfigurationDraft { - label: string; - provider: string; - model: string; -} - type PendingRestartSection = "runtime" | "browser" | "image"; type PendingRestartSections = Record; type RestartAwarePayload = { @@ -240,16 +244,35 @@ type RestartAwarePayload = { runtime_capabilities?: SettingsPayload["runtime_capabilities"]; }; type ProviderApiType = "auto" | "chat_completions" | "responses"; +type ProviderAdvancedField = NonNullable< + SettingsPayload["providers"][number]["advanced_fields"] +>[number]; type ProviderForm = { + displayName: string; apiKey: string; apiBase: string; apiType: ProviderApiType; proxy: string; + extraHeaders: string; + extraBody: string; + extraQuery: string; + thinkingStyle: string; + region: string; + profile: string; }; +type CustomProviderDraft = ProviderForm & { name: string }; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 200_000, 262_144, 500_000, 1_048_576] as const; const OAUTH_PROXY_PROVIDERS = new Set(["openai_codex", "xai_grok"]); +const CUSTOM_PROVIDER_CREATION_KEY = "__custom_provider__"; +const CUSTOM_PROVIDER_ADVANCED_FIELDS: ProviderAdvancedField[] = [ + "extra_headers", + "extra_body", + "extra_query", + "proxy", + "thinking_style", +]; const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "aihubmix", "atomic_chat", @@ -267,6 +290,45 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([ "volcengine", "volcengine_coding_plan", ]); + +function providerJsonValue(value: Record | null | undefined): string { + return value && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : ""; +} + +function providerFormFromRow( + provider: SettingsPayload["providers"][number], +): ProviderForm { + return { + displayName: provider.is_custom ? provider.label : "", + apiKey: "", + apiBase: provider.api_base ?? provider.default_api_base ?? "", + apiType: provider.api_type ?? "auto", + proxy: provider.proxy ?? "", + extraHeaders: providerJsonValue(provider.extra_headers), + extraBody: providerJsonValue(provider.extra_body), + extraQuery: providerJsonValue(provider.extra_query), + thinkingStyle: provider.thinking_style ?? "", + region: provider.region ?? "", + profile: provider.profile ?? "", + }; +} + +function emptyCustomProviderDraft(): CustomProviderDraft { + return { + name: "", + displayName: "", + apiKey: "", + apiBase: "", + apiType: "auto", + proxy: "", + extraHeaders: "", + extraBody: "", + extraQuery: "", + thinkingStyle: "", + region: "", + profile: "", + }; +} const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; const CLI_APPS_REFRESH_RETRY_MS = 2_000; const CLI_APPS_REFRESH_MAX_RETRIES = 30; @@ -363,22 +425,17 @@ interface SettingsViewProps { } function modelPresetValue(payload: SettingsPayload): string { - return payload.agent.model_preset || "default"; -} - -function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets"][number] | null { - return payload.model_presets.find((preset) => preset.is_default) ?? null; + return ( + payload.model_call_order?.[0] ?? + payload.model_presets.find((preset) => !preset.is_default)?.name ?? + "" + ); } function normalizeContextWindowTokens(value: number | null | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000; } -function editableDefaultProvider(payload: SettingsPayload): string { - const base = defaultPreset(payload); - return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? ""; -} - function settingsProviderRow( payload: SettingsPayload, provider: string | null | undefined, @@ -390,13 +447,14 @@ function settingsProviderRow( function settingsProviderConfigured( payload: SettingsPayload, provider: string | null | undefined, + resolvedProvider?: string | null, ): boolean { const row = settingsProviderRow(payload, provider); if (row) return row.configured; if (provider === "auto") { const resolvedRow = settingsProviderRow( payload, - payload.agent.resolved_provider ?? payload.agent.provider, + resolvedProvider ?? payload.agent.resolved_provider ?? payload.agent.provider, ); if (resolvedRow) return resolvedRow.configured; } @@ -406,9 +464,12 @@ function settingsProviderConfigured( const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = { model: "", provider: "", - modelPreset: "default", - presetLabel: "Default", + modelPreset: "", + presetLabel: "", + maxTokens: 8192, contextWindowTokens: 200_000, + temperature: 0.1, + reasoningEffort: "", timezone: "UTC", botName: "nanobot", botIcon: "", @@ -458,21 +519,26 @@ const DEFAULT_NETWORK_SAFETY_FORM: NetworkSafetySettingsUpdate = { webuiDefaultAccessMode: "default", }; -function agentDraftFromPayload(payload: SettingsPayload): AgentSettingsDraft { - const fallbackDefault = defaultPreset(payload); - const activePresetName = modelPresetValue(payload); +function agentDraftFromPayload( + payload: SettingsPayload, + preferredPresetName?: string, +): AgentSettingsDraft { + const activePresetName = preferredPresetName ?? modelPresetValue(payload); const activePreset = - payload.model_presets.find((preset) => preset.name === activePresetName) ?? fallbackDefault; + payload.model_presets.find( + (preset) => !preset.is_default && preset.name === activePresetName, + ) ?? null; return { model: activePreset?.model ?? payload.agent.model, - provider: activePreset?.is_default - ? editableDefaultProvider(payload) - : activePreset?.provider ?? editableDefaultProvider(payload), + provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "", modelPreset: activePresetName, presetLabel: activePreset?.label ?? activePresetName, + maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens, contextWindowTokens: normalizeContextWindowTokens( activePreset?.context_window_tokens ?? payload.agent.context_window_tokens, ), + temperature: activePreset?.temperature ?? payload.agent.temperature, + reasoningEffort: activePreset?.reasoning_effort ?? "", timezone: payload.agent.timezone, botName: payload.agent.bot_name, botIcon: payload.agent.bot_icon, @@ -583,13 +649,13 @@ export function SettingsView({ const [mcpPresetsLoading, setMcpPresetsLoading] = useState(true); const [automationsLoading, setAutomationsLoading] = useState(false); const [saving, setSaving] = useState(false); - const [modelConfigurationOpen, setModelConfigurationOpen] = useState(false); + const [modelPresetCreating, setModelPresetCreating] = useState(false); const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false); - const [modelConfigurationForm, setModelConfigurationForm] = useState({ - label: "", - provider: "", - model: "", - }); + const [modelCallOrderSaving, setModelCallOrderSaving] = useState(false); + const [modelMigrationSaving, setModelMigrationSaving] = useState(false); + const [modelPresetPendingDelete, setModelPresetPendingDelete] = + useState(null); + const modelPresetBeforeCreateRef = useRef(null); const [cliAppsAction, setCliAppsAction] = useState(null); const [nanobotFeatureAction, setNanobotFeatureAction] = useState(null); const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState(null); @@ -612,7 +678,6 @@ export function SettingsView({ const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState(initialSection); const [expandedProvider, setExpandedProvider] = useState(null); - const [providerQuery, setProviderQuery] = useState(""); const [appsQuery, setAppsQuery] = useState(""); const [channelsQuery, setChannelsQuery] = useState(""); const [automationsQuery, setAutomationsQuery] = useState(""); @@ -673,25 +738,32 @@ export function SettingsView({ const [form, setForm] = useState(() => initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT, ); - - const text = useCallback( - (key: string, fallback: string, options?: Record) => - t(key, { defaultValue: fallback, ...(options ?? {}) }), - [t], + const [modelCallOrder, setModelCallOrder] = useState( + () => initialSettings?.model_call_order ?? [], ); - const applyPayload = useCallback((payload: SettingsPayload) => { - setSettings(payload); - setForm(agentDraftFromPayload(payload)); - setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev)); - setImageGenerationForm(imageGenerationFormFromPayload(payload)); - setTranscriptionForm(transcriptionFormFromPayload(payload)); - setNetworkSafetyForm(networkSafetyFormFromPayload(payload)); - if (payload.restart_required_sections) { - setPendingRestartSections(pendingRestartSectionsFromPayload(payload)); - } - onSettingsChange?.(payload); - }, [onSettingsChange]); + const applyPayload = useCallback( + ( + payload: SettingsPayload, + options: { preserveAgentForm?: boolean } = {}, + ) => { + setSettings(payload); + if (!options.preserveAgentForm) { + setForm(agentDraftFromPayload(payload)); + setModelPresetCreating(false); + } + setModelCallOrder(payload.model_call_order ?? []); + setWebSearchForm((prev) => webSearchFormFromPayload(payload, prev)); + setImageGenerationForm(imageGenerationFormFromPayload(payload)); + setTranscriptionForm(transcriptionFormFromPayload(payload)); + setNetworkSafetyForm(networkSafetyFormFromPayload(payload)); + if (payload.restart_required_sections) { + setPendingRestartSections(pendingRestartSectionsFromPayload(payload)); + } + onSettingsChange?.(payload); + }, + [onSettingsChange], + ); const closeXaiOAuthFlow = useCallback(() => { xaiOAuthFlowRef.current = null; @@ -953,12 +1025,7 @@ export function SettingsView({ setProviderForms((prev) => { const next = { ...prev }; for (const provider of settings.providers) { - next[provider.name] = { - apiKey: next[provider.name]?.apiKey ?? "", - apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "", - apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto", - proxy: next[provider.name]?.proxy ?? provider.proxy ?? "", - }; + next[provider.name] = next[provider.name] ?? providerFormFromRow(provider); } return next; }); @@ -966,18 +1033,18 @@ export function SettingsView({ const modelDirty = useMemo(() => { if (!settings) return false; - const activePresetName = modelPresetValue(settings); - const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); - if (!selectedPreset) return form.modelPreset !== activePresetName; - const selectedProvider = selectedPreset.is_default - ? editableDefaultProvider(settings) - : selectedPreset.provider; + const selectedPreset = settings.model_presets.find( + (preset) => !preset.is_default && preset.name === form.modelPreset, + ); + if (!selectedPreset) return false; return ( - form.modelPreset !== activePresetName || form.model !== selectedPreset.model || - form.provider !== selectedProvider || + form.provider !== selectedPreset.provider || + form.maxTokens !== selectedPreset.max_tokens || form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) || - (!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label) + form.temperature !== selectedPreset.temperature || + form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") || + form.presetLabel.trim() !== selectedPreset.label ); }, [form, settings]); @@ -1097,37 +1164,98 @@ export function SettingsView({ ); const saveModelSettings = async () => { - if (!settings || !modelDirty || saving) return; + if ( + !settings || + saving || + modelCallOrderSaving || + modelConfigurationSaving + ) { + return; + } + + if (modelPresetCreating) { + const label = form.presetLabel.trim(); + const provider = form.provider.trim(); + const model = form.model.trim(); + if ( + !label || + !provider || + !model || + form.maxTokens <= 0 || + form.contextWindowTokens <= 0 || + form.temperature < 0 || + form.temperature > 2 + ) { + return; + } + setModelConfigurationSaving(true); + try { + const payload = await createModelConfiguration(token, { + label, + provider, + model, + maxTokens: form.maxTokens, + contextWindowTokens: form.contextWindowTokens, + temperature: form.temperature, + reasoningEffort: form.reasoningEffort || null, + }); + const createdPreset = payload.created_model_preset; + const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null; + applyPayload(payload); + if (createdPreset) { + setForm(agentDraftFromPayload(payload, createdPreset)); + } + + let finalPayload = payload; + if (nextOrder) { + const orderedPayload = await updateModelCallOrder(token, nextOrder); + applyPayload(orderedPayload); + finalPayload = orderedPayload; + } + if (createdPreset) { + setForm(agentDraftFromPayload(finalPayload, createdPreset)); + } + modelPresetBeforeCreateRef.current = null; + onModelNameChange(finalPayload.agent.model || null); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setModelConfigurationSaving(false); + } + return; + } + + if (!modelDirty) return; + const selectedPreset = settings.model_presets.find( + (preset) => !preset.is_default && preset.name === form.modelPreset, + ); + if (!selectedPreset) return; + const reasoningEffort = form.reasoningEffort || null; setSaving(true); try { - const selectedPreset = settings.model_presets.find((preset) => preset.name === form.modelPreset); - let payload: SettingsPayload; - if (selectedPreset && !selectedPreset.is_default) { - payload = await updateModelConfiguration(token, { - name: selectedPreset.name, - label: form.presetLabel.trim(), - model: form.model, - provider: form.provider, - ...(form.contextWindowTokens !== selectedPreset.context_window_tokens - ? { contextWindowTokens: form.contextWindowTokens } - : {}), - }); - } else { - const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model; - const defaultProvider = editableDefaultProvider(settings); - const defaultContextWindowTokens = normalizeContextWindowTokens( - defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens, - ); - payload = await updateSettings(token, { - modelPreset: form.modelPreset, - ...(form.model !== defaultModel ? { model: form.model } : {}), - ...(form.provider !== defaultProvider ? { provider: form.provider } : {}), - ...(form.contextWindowTokens !== defaultContextWindowTokens - ? { contextWindowTokens: form.contextWindowTokens } - : {}), - }); - } + const payload = await updateModelConfiguration(token, { + name: selectedPreset.name, + label: + form.presetLabel.trim() !== selectedPreset.label + ? form.presetLabel.trim() + : undefined, + model: form.model !== selectedPreset.model ? form.model : undefined, + provider: form.provider !== selectedPreset.provider ? form.provider : undefined, + maxTokens: + form.maxTokens !== selectedPreset.max_tokens ? form.maxTokens : undefined, + contextWindowTokens: + form.contextWindowTokens !== + normalizeContextWindowTokens(selectedPreset.context_window_tokens) + ? form.contextWindowTokens + : undefined, + temperature: + form.temperature !== selectedPreset.temperature ? form.temperature : undefined, + reasoningEffort: + reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined, + }); applyPayload(payload); + setForm(agentDraftFromPayload(payload, selectedPreset.name)); onModelNameChange(payload.agent.model || null); setError(null); } catch (err) { @@ -1137,42 +1265,107 @@ export function SettingsView({ } }; - const openModelConfigurationDialog = () => { - if (!settings) return; - const currentProvider = settings.agent.provider; + const beginModelPresetCreation = () => { + if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return; + const primaryPreset = settings.model_presets.find( + (preset) => !preset.is_default && preset.name === settings.model_call_order?.[0], + ); + const currentProvider = primaryPreset?.provider === "auto" + ? primaryPreset.resolved_provider ?? settings.agent.resolved_provider + : primaryPreset?.provider ?? settings.agent.provider; const provider = configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ?? configuredModelProviderOptions[0]?.name ?? ""; - setModelConfigurationForm({ - label: "", + modelPresetBeforeCreateRef.current = form.modelPreset; + setForm((prev) => ({ + ...prev, + modelPreset: "", + presetLabel: "", provider, model: "", - }); - setModelConfigurationOpen(true); + maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens, + contextWindowTokens: normalizeContextWindowTokens( + primaryPreset?.context_window_tokens ?? settings.agent.context_window_tokens, + ), + temperature: primaryPreset?.temperature ?? settings.agent.temperature, + reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "", + })); + setModelPresetCreating(true); }; - const handleCreateModelConfiguration = async () => { - if (modelConfigurationSaving) return; - const label = modelConfigurationForm.label.trim(); - const provider = modelConfigurationForm.provider.trim(); - const model = modelConfigurationForm.model.trim(); - if (!label || !provider || !model) return; - setModelConfigurationSaving(true); + const cancelModelPresetCreation = () => { + if (!settings || modelConfigurationSaving) return; + const previousPreset = modelPresetBeforeCreateRef.current; + setModelPresetCreating(false); + setForm(agentDraftFromPayload(settings, previousPreset ?? undefined)); + modelPresetBeforeCreateRef.current = null; + }; + + const changeModelCallOrder = async (nextOrder: string[]) => { + const unchanged = + nextOrder.length === modelCallOrder.length && + nextOrder.every((name, index) => name === modelCallOrder[index]); + if ( + !settings || + saving || + modelCallOrderSaving || + modelConfigurationSaving || + nextOrder.length === 0 || + unchanged + ) { + return; + } + const previousOrder = [...modelCallOrder]; + setModelCallOrder(nextOrder); + setModelCallOrderSaving(true); try { - const payload = await createModelConfiguration(token, { - label, - provider, - model, - }); + const payload = await updateModelCallOrder(token, nextOrder); + applyPayload(payload, { preserveAgentForm: true }); + onModelNameChange(payload.agent.model || null); + setError(null); + } catch (err) { + setModelCallOrder(previousOrder); + setError((err as Error).message); + } finally { + setModelCallOrderSaving(false); + } + }; + + const handleMigrateModelConfigurations = async () => { + if (modelMigrationSaving) return; + setModelMigrationSaving(true); + try { + const payload = await migrateModelConfigurations(token); applyPayload(payload); onModelNameChange(payload.agent.model || null); - setModelConfigurationOpen(false); setError(null); } catch (err) { setError((err as Error).message); } finally { - setModelConfigurationSaving(false); + setModelMigrationSaving(false); + } + }; + + const handleDeleteModelConfiguration = async () => { + if ( + !modelPresetPendingDelete || + saving || + modelCallOrderSaving || + modelConfigurationSaving + ) { + return; + } + setSaving(true); + try { + const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name); + applyPayload(payload); + setModelPresetPendingDelete(null); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); } }; @@ -1306,12 +1499,7 @@ export function SettingsView({ const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; const isOauthProvider = provider.auth_type === "oauth"; - const providerForm = providerForms[providerName] ?? { - apiKey: "", - apiBase: "", - apiType: "auto", - proxy: provider.proxy ?? "", - }; + const providerForm = providerForms[providerName] ?? providerFormFromRow(provider); const apiKey = providerForm.apiKey.trim(); const apiKeyRequired = provider.api_key_required ?? true; if (!isOauthProvider && !provider.configured && apiKeyRequired && !apiKey) { @@ -1326,20 +1514,27 @@ export function SettingsView({ ? "azure" : null; if (supportName && !(await installCapabilities([supportName]))) return; - const payload = await updateProviderSettings( - token, - isOauthProvider - ? { - provider: providerName, - proxy: providerForm.proxy.trim(), - } - : { - provider: providerName, - apiKey: apiKey || undefined, - apiBase: providerForm.apiBase.trim(), - apiType: providerForm.apiType, - }, - ); + const update: ProviderSettingsUpdate = { provider: providerName }; + if (!isOauthProvider) { + update.apiKey = apiKey || undefined; + update.apiBase = providerForm.apiBase.trim(); + if (provider.is_custom) update.displayName = providerForm.displayName.trim(); + } + for (const field of provider.advanced_fields ?? []) { + if (field === "api_type") update.apiType = providerForm.apiType; + if (field === "proxy") update.proxy = providerForm.proxy.trim(); + if (field === "extra_headers") { + update.extraHeaders = providerForm.extraHeaders.trim(); + } + if (field === "extra_body") update.extraBody = providerForm.extraBody.trim(); + if (field === "extra_query") update.extraQuery = providerForm.extraQuery.trim(); + if (field === "thinking_style") { + update.thinkingStyle = providerForm.thinkingStyle.trim(); + } + if (field === "region") update.region = providerForm.region.trim(); + if (field === "profile") update.profile = providerForm.profile.trim(); + } + const payload = await updateProviderSettings(token, update); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); @@ -1348,14 +1543,19 @@ export function SettingsView({ setProviderForms((prev) => ({ ...prev, [providerName]: { + ...providerForm, + displayName: providerForm.displayName.trim(), apiKey: "", apiBase: providerForm.apiBase.trim(), - apiType: providerForm.apiType, proxy: providerForm.proxy.trim(), + thinkingStyle: providerForm.thinkingStyle.trim(), + region: providerForm.region.trim(), + profile: providerForm.profile.trim(), }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); + if (!isOauthProvider) setExpandedProvider(null); setError(null); } catch (err) { setError((err as Error).message); @@ -1364,6 +1564,32 @@ export function SettingsView({ } }; + const createCustomProvider = async (draft: CustomProviderDraft): Promise => { + if (providerSaving) return false; + setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY); + try { + const payload = await createProviderSettings(token, { + name: draft.name.trim(), + apiKey: draft.apiKey.trim() || undefined, + apiBase: draft.apiBase.trim(), + proxy: draft.proxy.trim(), + extraHeaders: draft.extraHeaders.trim(), + extraBody: draft.extraBody.trim(), + extraQuery: draft.extraQuery.trim(), + thinkingStyle: draft.thinkingStyle.trim(), + }); + applyPayload(payload); + setExpandedProvider(null); + setError(null); + return true; + } catch (err) { + setError((err as Error).message); + return false; + } finally { + setProviderSaving(null); + } + }; + const runProviderOAuth = async (providerName: string, action: "login" | "logout") => { if (providerSaving) return; let popup: Window | null = null; @@ -1503,12 +1729,7 @@ export function SettingsView({ if (!provider) return; setProviderForms((prev) => ({ ...prev, - [providerName]: { - apiKey: "", - apiBase: provider.api_base ?? provider.default_api_base ?? "", - apiType: provider.api_type ?? "auto", - proxy: provider.proxy ?? "", - }, + [providerName]: providerFormFromRow(provider), })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setEditingProviderKeys((prev) => ({ ...prev, [providerName]: false })); @@ -1559,10 +1780,14 @@ export function SettingsView({ setProviderForms((forms) => ({ ...forms, [providerName]: { + ...(forms[providerName] ?? providerFormFromRow( + settings?.providers.find((provider) => provider.name === providerName) ?? { + name: providerName, + label: providerName, + configured: false, + }, + )), apiKey: "", - apiBase: forms[providerName]?.apiBase ?? "", - apiType: forms[providerName]?.apiType ?? "auto", - proxy: forms[providerName]?.proxy ?? "", }, })); setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); @@ -1797,12 +2022,25 @@ export function SettingsView({ setForm={setForm} settings={settings} dirty={modelDirty} + creating={modelPresetCreating} + creatingSaving={modelConfigurationSaving} + callOrder={modelCallOrder} saving={saving} + orderSaving={modelCallOrderSaving || modelConfigurationSaving} + migrationSaving={modelMigrationSaving} showBrandLogos={localPrefs.brandLogos} providerSaving={providerSaving} + onChangeCallOrder={changeModelCallOrder} onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")} onSave={saveModelSettings} - onCreateConfiguration={openModelConfigurationDialog} + onMigrate={handleMigrateModelConfigurations} + onBeginCreate={beginModelPresetCreation} + onCancelCreate={cancelModelPresetCreation} + onSelectConfiguration={() => { + setModelPresetCreating(false); + modelPresetBeforeCreateRef.current = null; + }} + onDeleteConfiguration={setModelPresetPendingDelete} /> ({ ...prev, [provider]: { - apiKey: prev[provider]?.apiKey ?? "", - apiBase: prev[provider]?.apiBase ?? "", - apiType: prev[provider]?.apiType ?? "auto", - proxy: prev[provider]?.proxy ?? "", + ...(prev[provider] ?? providerFormFromRow( + settings.providers.find((row) => row.name === provider) ?? { + name: provider, + label: provider, + configured: false, + }, + )), ...value, }, })) } onSaveProvider={saveProvider} + onCreateCustomProvider={createCustomProvider} onProviderOAuthLogin={(provider) => runProviderOAuth(provider, "login")} onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")} - onResetProviderDraft={resetProviderDraft} imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} @@ -2050,15 +2289,13 @@ export function SettingsView({ /> ) : null} - { + if (!open) setModelPresetPendingDelete(null); + }} + onConfirm={handleDeleteModelConfiguration} /> -
- {!showSidebar ? ( + {!showSidebar ? ( +
- ) : null} - {showSidebar ? ( -

- {t("settings.sidebar.title")} -

- ) : null} -

- {text(`settings.nav.${activeSection}`, titleForSection(activeSection))} -

-
+

+ {t(`settings.nav.${activeSection}`, { + defaultValue: standaloneSectionTitle(activeSection), + })} +

+
+ ) : null} {loading ? (
@@ -2187,7 +2421,10 @@ function visibleWebuiDefaultAccessMode(mode: string | null | undefined): WebuiDe return mode === "full" ? "full" : "default"; } -function titleForSection(section: SettingsSectionKey): string { +function standaloneSectionTitle(section: SettingsSectionKey): string { + if (section === "apps") return "Apps"; + if (section === "automations") return "Automations"; + if (section === "skills") return "Skills"; return SETTINGS_NAV_ITEMS.find((item) => item.key === section)?.fallback ?? "Settings"; } @@ -2333,7 +2570,12 @@ function OverviewSettings({ }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const activePreset = settings.agent.model_preset || "default"; + const activePresetName = settings.agent.model_preset; + const activePreset = + activePresetName && activePresetName !== "default" + ? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ?? + activePresetName + : null; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; const activeProviderConfigured = settingsProviderConfigured(settings, activeProvider); const activeProviderLabel = providerDisplayLabel(settings.providers, activeProvider); @@ -2341,7 +2583,7 @@ function OverviewSettings({ ? settings.agent.model : tx("settings.values.notConfigured", "Not configured"); const activeModelCaption = activeProviderConfigured - ? `${activeProvider} · ${activePreset}` + ? [activeProvider, activePreset].filter(Boolean).join(" · ") : activeProviderLabel || settings.agent.model ? [activeProviderLabel, settings.agent.model].filter(Boolean).join(" · ") : tx("settings.byok.noConfiguredProviders", "No configured providers"); @@ -2791,117 +3033,60 @@ function XaiOAuthLoginDialog({ ); } -function NewModelConfigurationDialog({ - open, - draft, - providers, - saving, - showProviderLogos, +function ModelPresetDeleteDialog({ + preset, + deleting, onOpenChange, - onChangeDraft, - onSave, + onConfirm, }: { - open: boolean; - draft: ModelConfigurationDraft; - providers: Array<{ name: string; label: string }>; - saving: boolean; - showProviderLogos: boolean; + preset: SettingsPayload["model_presets"][number] | null; + deleting: boolean; onOpenChange: (open: boolean) => void; - onChangeDraft: Dispatch>; - onSave: () => void; + onConfirm: () => void; }) { const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const canSave = Boolean(draft.label.trim() && draft.provider.trim() && draft.model.trim()); - + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); return ( - - -
{ - event.preventDefault(); - onSave(); - }} - > - - - {tx("settings.models.newConfiguration", "New model configuration")} - - - {tx("settings.models.newConfigurationHelp", "Save a provider and model as a one-click option.")} - - - -
- - -
- -
- - {tx("settings.rows.provider", "Provider")} - - - onChangeDraft((prev) => ({ ...prev, provider })) - } - /> -
-
-
- - - - - -
+ + + + + {tx("settings.models.deletePresetTitle", "Delete model preset?")} + + + {tx( + "settings.models.deletePresetHelp", + "This removes the preset “{{name}}”. Provider credentials are not affected.", + { name: preset?.label ?? "" }, + )} + + + + + + ); @@ -2937,198 +3122,916 @@ function ModelsSettings({ setForm, settings, dirty, + creating, + creatingSaving, + callOrder, saving, + orderSaving, + migrationSaving, showBrandLogos, providerSaving, + onChangeCallOrder, onProviderOAuthLogin, onSave, - onCreateConfiguration, + onMigrate, + onBeginCreate, + onCancelCreate, + onSelectConfiguration, + onDeleteConfiguration, }: { token: string; form: AgentSettingsDraft; setForm: Dispatch>; settings: SettingsPayload; dirty: boolean; + creating: boolean; + creatingSaving: boolean; + callOrder: string[]; saving: boolean; + orderSaving: boolean; + migrationSaving: boolean; showBrandLogos: boolean; providerSaving: string | null; + onChangeCallOrder: (order: string[]) => void; onProviderOAuthLogin: (provider: string) => void; onSave: () => void; - onCreateConfiguration: () => void; + onMigrate: () => void; + onBeginCreate: () => void; + onCancelCreate: () => void; + onSelectConfiguration: () => void; + onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void; }) { const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const tx = (key: string, fallback: string, values?: Record) => + t(key, { defaultValue: fallback, ...(values ?? {}) }); + const [editorOpen, setEditorOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState(null); + const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState(null); + const namedPresets = settings.model_presets.filter((preset) => !preset.is_default); + const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset])); + const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name)); + const presetRows = [ + ...callOrder.map((name, orderIndex) => ({ + name, + orderIndex, + preset: namedPresetsByName.get(name), + })), + ...unorderedPresets.map((preset) => ({ + name: preset.name, + orderIndex: -1, + preset, + })), + ]; + const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null; + useEffect(() => { + setAdvancedOpen(false); + }, [editorOpen, selectedPreset?.name]); + const configuredProviders = settings.providers.filter((provider) => provider.configured); - const showAutoProvider = defaultPreset(settings)?.provider === "auto" || form.provider === "auto"; - const selectableProviders = uniqueProviders(configuredProviders); + const selectedProvider = settings.providers.find((provider) => provider.name === form.provider); + const selectableProviders = uniqueProviders([ + ...configuredProviders, + ...(selectedProvider ? [selectedProvider] : []), + ]); + const showAutoProvider = selectedPreset?.provider === "auto" || form.provider === "auto"; const providerOptions = showAutoProvider ? [{ name: "auto", label: tx("settings.values.auto", "Auto") }, ...selectableProviders] : selectableProviders; const providerValue = providerOptions.some((provider) => provider.name === form.provider) ? form.provider : ""; - const selectedPreset = - settings.model_presets.find((preset) => preset.name === form.modelPreset) ?? null; - const selectedProvider = settings.providers.find((provider) => provider.name === form.provider); const selectedProviderNeedsSignIn = selectedProvider?.auth_type === "oauth" && !selectedProvider.configured; const selectedProviderSigningIn = providerSaving === selectedProvider?.name; - const selectedProviderConfigured = settingsProviderConfigured(settings, form.provider); + const selectedProviderConfigured = settingsProviderConfigured( + settings, + form.provider, + selectedPreset?.resolved_provider, + ); const modelFieldsMissing = !form.model.trim() || !form.provider.trim() || - Boolean(selectedPreset && !selectedPreset.is_default && !form.presetLabel.trim()); + !form.presetLabel.trim() || + form.maxTokens <= 0 || + form.temperature < 0 || + form.temperature > 2; + const selectedPresetReferenced = Boolean( + selectedPreset && (settings.model_call_order ?? []).includes(selectedPreset.name), + ); + const callOrderBusy = orderSaving || saving; + const selectPreset = (preset: SettingsPayload["model_presets"][number]) => { + const toggleCurrentPreset = !creating && selectedPreset?.name === preset.name; + onSelectConfiguration(); + if (toggleCurrentPreset) { + setEditorOpen((open) => !open); + return; + } + setForm((prev) => ({ + ...prev, + modelPreset: preset.name, + model: preset.model, + provider: preset.provider, + presetLabel: preset.label, + maxTokens: preset.max_tokens, + contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens), + temperature: preset.temperature, + reasoningEffort: preset.reasoning_effort ?? "", + })); + setEditorOpen(true); + }; + + const moveCallOrderItem = (index: number, offset: -1 | 1) => { + if (callOrderBusy) return; + const nextIndex = index + offset; + if (nextIndex < 0 || nextIndex >= callOrder.length) return; + const next = [...callOrder]; + [next[index], next[nextIndex]] = [next[nextIndex], next[index]]; + onChangeCallOrder(next); + }; + + const removeCallOrderItem = (index: number) => { + if (callOrderBusy || callOrder.length <= 1) return; + onChangeCallOrder(callOrder.filter((_, itemIndex) => itemIndex !== index)); + }; + + const dropCallOrderItem = (targetIndex: number) => { + if ( + callOrderBusy || + draggedCallOrderIndex === null || + draggedCallOrderIndex === targetIndex + ) { + setDraggedCallOrderIndex(null); + setDragOverCallOrderIndex(null); + return; + } + const next = [...callOrder]; + const moved = next.splice(draggedCallOrderIndex, 1)[0]; + if (!moved) { + setDraggedCallOrderIndex(null); + setDragOverCallOrderIndex(null); + return; + } + next.splice(targetIndex, 0, moved); + setDraggedCallOrderIndex(null); + setDragOverCallOrderIndex(null); + onChangeCallOrder(next); + }; + return (
+ + {tx("settings.models.presets", "Model presets")} + - - { - const nextPreset = settings.model_presets.find((preset) => preset.name === modelPreset); - setForm((prev) => ({ - ...prev, - modelPreset, - model: nextPreset?.model ?? prev.model, - provider: nextPreset?.is_default - ? editableDefaultProvider(settings) - : nextPreset?.provider ?? prev.provider, - presetLabel: nextPreset?.label ?? modelPreset, - contextWindowTokens: normalizeContextWindowTokens( - nextPreset?.context_window_tokens ?? prev.contextWindowTokens, - ), - })); - }} - onCreateConfiguration={onCreateConfiguration} - /> - - {selectedPreset && !selectedPreset.is_default ? ( - + {!settings.model_call_order_editable ? ( +
+
+ + + +
+

+ {tx("settings.models.convertTitle", "Convert the current model setup")} +

+

+ {tx( + "settings.models.convertHelp", + "Turn the existing primary and fallback models into presets so their order can be managed here.", + )} +

+
+
+ +
+ ) : ( + <> +
+ {presetRows.map(({ name, orderIndex, preset }) => { + const ordered = orderIndex >= 0; + const provider = preset + ? modelPresetProviderKey(preset, settings) + : settings.agent.resolved_provider ?? settings.agent.provider; + const presetConfigured = preset + ? settingsProviderConfigured( + settings, + preset.provider, + preset.resolved_provider, + ) + : true; + const isDropTarget = + ordered && + dragOverCallOrderIndex === orderIndex && + draggedCallOrderIndex !== orderIndex; + const dropAfterTarget = + isDropTarget && + draggedCallOrderIndex !== null && + draggedCallOrderIndex < orderIndex; + return ( +
{ + if (!ordered || callOrderBusy) { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", name); + setDraggedCallOrderIndex(orderIndex); + setDragOverCallOrderIndex(orderIndex); + }} + onDragEnd={() => { + setDraggedCallOrderIndex(null); + setDragOverCallOrderIndex(null); + }} + onDragEnter={(event) => { + if (ordered && draggedCallOrderIndex !== null) { + event.preventDefault(); + setDragOverCallOrderIndex(orderIndex); + } + }} + onDragOver={(event) => { + if (!ordered || draggedCallOrderIndex === null) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + if (!ordered) return; + event.preventDefault(); + dropCallOrderItem(orderIndex); + }} + onKeyDown={(event) => { + if (event.currentTarget !== event.target) return; + if (ordered && event.key === "ArrowUp") { + event.preventDefault(); + moveCallOrderItem(orderIndex, -1); + } else if (ordered && event.key === "ArrowDown") { + event.preventDefault(); + moveCallOrderItem(orderIndex, 1); + } else if ((event.key === "Enter" || event.key === " ") && preset) { + event.preventDefault(); + selectPreset(preset); + } + }} + className={cn( + "group relative flex min-h-[76px] select-none items-center gap-3 px-4 py-3 outline-none transition-[background-color,opacity] duration-150 sm:px-5", + ordered && + (callOrderBusy + ? "cursor-wait" + : "cursor-grab active:cursor-grabbing"), + "hover:bg-muted/25", + isDropTarget && + !dropAfterTarget && + "before:absolute before:inset-x-4 before:top-0 before:z-10 before:h-0.5 before:rounded-full before:bg-foreground sm:before:inset-x-5", + isDropTarget && + dropAfterTarget && + "after:absolute after:inset-x-4 after:bottom-0 after:z-10 after:h-0.5 after:rounded-full after:bg-foreground sm:after:inset-x-5", + ordered && draggedCallOrderIndex === orderIndex && "opacity-35", + "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + )} + > + {ordered ? ( + + ) : ( + + )} + + +
+ ); + })} +
+
+ {!creating ? ( + + ) : ( + + )} + {orderSaving ? ( + + + + {tx("settings.actions.saving", "Saving...")} + + + ) : null} +
+ + )} + + {editorOpen && (selectedPreset || creating) ? ( + <> +
+ + {creating + ? tx("settings.models.newPreset", "New model preset") + : tx("settings.models.editPreset", "Edit preset")} + +
+ setForm((prev) => ({ ...prev, presetLabel: event.target.value })) } className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" /> - ) : null} - - - setForm((prev) => ({ - ...prev, - provider, - model: provider === prev.provider ? prev.model : "", - })) - } - /> - - {selectedProviderNeedsSignIn ? ( - - + + + setForm((prev) => ({ + ...prev, + provider, + model: provider === prev.provider ? prev.model : "", + })) + } + /> - ) : null} - - setForm((prev) => ({ ...prev, model }))} - /> - - - ({ - value: String(tokens), - label: - tokens === 1_048_576 - ? "1M" - : tokens === 500_000 - ? "500K" - : tokens === 262_144 - ? "256K" - : tokens === 200_000 - ? "200K" - : "64K", - }))} - onChange={(value) => - setForm((prev) => ({ - ...prev, - contextWindowTokens: normalizeContextWindowTokens(Number(value)), - })) - } - /> - - + {selectedProviderNeedsSignIn ? ( + + + + ) : null} + + setForm((prev) => ({ ...prev, model }))} + /> + + + {advancedOpen ? ( +
+ setForm((prev) => ({ ...prev, ...value }))} + /> +
+ ) : null} +
+ {creating ? ( + + ) : selectedPreset ? ( + + ) : null} +
+ +
+
+ + ) : null}
); } +function ModelAdvancedFields({ + maxTokens, + contextWindowTokens, + temperature, + reasoningEffort, + onChange, +}: { + maxTokens: number; + contextWindowTokens: number; + temperature: number; + reasoningEffort: string; + onChange: ( + value: Partial< + Pick< + AgentSettingsDraft, + "maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort" + > + >, + ) => void; +}) { + const { t } = useTranslation(); + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const contextWindowOptions = Array.from( + new Set([...CONTEXT_WINDOW_TOKEN_OPTIONS, contextWindowTokens]), + ).sort((left, right) => left - right); + return ( +
+
+ + +
+
+ + {tx("settings.rows.contextWindow", "Context window")} + + ({ + value: String(tokens), + label: formatModelContextWindow(tokens), + }))} + onChange={(value) => + onChange({ contextWindowTokens: normalizeContextWindowTokens(Number(value)) }) + } + /> +
+ +
+ ); +} + +function ProviderAdvancedOptions({ + fields, + form, + onChange, + footer, +}: { + fields: ProviderAdvancedField[]; + form: ProviderForm; + onChange: (value: Partial) => void; + footer?: ReactNode; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const enabled = new Set(fields); + if (enabled.size === 0) return null; + + const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const thinkingStyleOptions = [ + { value: "", label: tx("settings.values.default", "Default") }, + { value: "thinking_type", label: "thinking_type" }, + { value: "enable_thinking", label: "enable_thinking" }, + { value: "reasoning_split", label: "reasoning_split" }, + ]; + + return ( +
+ + {open ? ( +
+
+ {enabled.has("api_type") ? ( + + ) : null} + {enabled.has("thinking_style") ? ( + + ) : null} + {enabled.has("proxy") ? ( + + ) : null} + {enabled.has("region") ? ( + + ) : null} + {enabled.has("profile") ? ( + + ) : null} + {enabled.has("extra_headers") ? ( +