feat(webui): simplify model preset settings (#5061)
This commit is contained in:
+21
-2
@@ -518,10 +518,29 @@ class AgentLoop:
|
||||
self.runtime_resolver.invalidate()
|
||||
self._publish_runtime_selection(self.runtime_resolver.runtime)
|
||||
|
||||
def runtime_for_session(self, session: Session) -> LLMRuntime:
|
||||
def runtime_for_session(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
recover_removed: bool = True,
|
||||
) -> LLMRuntime:
|
||||
"""Resolve the immutable runtime selected by one session."""
|
||||
name = model_preset_from_metadata(session.metadata)
|
||||
return self.llm_runtime() if name is None else self.runtime_resolver.resolve_preset(name)
|
||||
if name is None:
|
||||
return self.llm_runtime()
|
||||
try:
|
||||
return self.runtime_resolver.resolve_preset(name)
|
||||
except KeyError:
|
||||
if not recover_removed or name in self.runtime_resolver.model_presets:
|
||||
raise
|
||||
logger.warning(
|
||||
"Session '{}' references removed model preset '{}'; falling back to default",
|
||||
session.key,
|
||||
name,
|
||||
)
|
||||
session.metadata.pop(SESSION_MODEL_PRESET_METADATA_KEY, None)
|
||||
self.sessions.save(session)
|
||||
return self.llm_runtime()
|
||||
|
||||
def set_session_model_preset(
|
||||
self,
|
||||
|
||||
@@ -2111,6 +2111,36 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert provider_body["image_generation"]["provider_configured"] is True
|
||||
assert "sk-or-test" not in provider_updated.text
|
||||
|
||||
custom_provider_created = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||
headers={
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": json.dumps(
|
||||
{
|
||||
"name": "Company Gateway",
|
||||
"apiBase": "https://gateway.example/v1",
|
||||
"apiKey": "sk-company",
|
||||
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
|
||||
"extraBody": json.dumps({"service_tier": "priority"}),
|
||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"thinkingStyle": "enable_thinking",
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
assert custom_provider_created.status_code == 200
|
||||
custom_provider_body = custom_provider_created.json()
|
||||
custom_provider_name = custom_provider_body["created_provider"]
|
||||
custom_provider_rows = {
|
||||
provider["name"]: provider for provider in custom_provider_body["providers"]
|
||||
}
|
||||
assert custom_provider_rows[custom_provider_name]["label"] == "Company Gateway"
|
||||
assert custom_provider_rows[custom_provider_name]["extra_headers"] == {
|
||||
"X-Tenant": "engineering"
|
||||
}
|
||||
assert "sk-company" not in custom_provider_created.text
|
||||
|
||||
local_provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||
@@ -2160,8 +2190,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
assert created_preset.status_code == 200
|
||||
created_body = created_preset.json()
|
||||
assert created_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert created_body["agent"]["model"] == "openai/gpt-4.1-mini"
|
||||
assert created_body["created_model_preset"] == "fast-writing"
|
||||
assert created_body["agent"]["model_preset"] == "deep"
|
||||
assert created_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
assert created_body["model_call_order"] == ["deep"]
|
||||
created_presets = {
|
||||
preset["name"]: preset for preset in created_body["model_presets"]
|
||||
}
|
||||
@@ -2176,13 +2208,25 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
assert updated_preset_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert updated_preset_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert updated_preset_body["agent"]["model_preset"] == "deep"
|
||||
assert updated_preset_body["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||
updated_presets = {
|
||||
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
call_order_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-call-order/update"
|
||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert call_order_updated.status_code == 200
|
||||
call_order_body = call_order_updated.json()
|
||||
assert call_order_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
@@ -2269,6 +2313,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||
assert saved.agents.defaults.provider == "atomic_chat"
|
||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||
@@ -2279,6 +2324,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.providers.openrouter.api_key == "sk-or-next"
|
||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||
assert saved.providers.atomic_chat.api_base == "http://localhost:1337/v1"
|
||||
custom_provider = saved.providers.model_extra[custom_provider_name]
|
||||
assert custom_provider.display_name == "Company Gateway"
|
||||
assert custom_provider.api_base == "https://gateway.example/v1"
|
||||
assert custom_provider.extra_body == {"service_tier": "priority"}
|
||||
assert saved.tools.web.search.provider == "searxng"
|
||||
assert saved.tools.web.search.api_key == ""
|
||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
||||
|
||||
@@ -323,7 +323,7 @@ def _command_error_message(exc: Exception) -> str:
|
||||
def _model_command_status(loop, session) -> str:
|
||||
names = _model_preset_names(loop)
|
||||
try:
|
||||
runtime = loop.runtime_for_session(session)
|
||||
runtime = loop.runtime_for_session(session, recover_removed=False)
|
||||
except (KeyError, ValueError) as exc:
|
||||
return "\n".join([
|
||||
"## Model",
|
||||
|
||||
@@ -184,6 +184,11 @@ class AgentsConfig(Base):
|
||||
class ProviderConfig(Base):
|
||||
"""LLM provider configuration."""
|
||||
|
||||
# User-facing name for dynamic custom providers.
|
||||
display_name: str | None = Field(
|
||||
default=None,
|
||||
exclude_if=lambda value: value is None,
|
||||
)
|
||||
api_key: str | None = Field(default=None, repr=False)
|
||||
api_base: str | None = None
|
||||
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface
|
||||
|
||||
@@ -56,7 +56,11 @@ def _make_provider_core(
|
||||
if provider_name and not spec and p:
|
||||
if not p.api_base:
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
|
||||
spec = create_dynamic_spec(
|
||||
provider_name,
|
||||
display_name=(p.display_name or "") if p else "",
|
||||
thinking_style=(p.thinking_style or "") if p else "",
|
||||
)
|
||||
if spec and spec.is_transcription_only:
|
||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
@@ -239,6 +243,7 @@ def provider_signature(
|
||||
fallback.reasoning_effort,
|
||||
fallback.context_window_tokens,
|
||||
getattr(fp, "proxy", None) if fp else None,
|
||||
fp.thinking_style if fp else None,
|
||||
)
|
||||
|
||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||
@@ -259,6 +264,7 @@ def provider_signature(
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
getattr(p, "proxy", None) if p else None,
|
||||
p.thinking_style if p else None,
|
||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
@@ -729,7 +729,12 @@ def find_by_name(name: str) -> ProviderSpec | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
||||
def create_dynamic_spec(
|
||||
name: str,
|
||||
*,
|
||||
display_name: str = "",
|
||||
thinking_style: str = "",
|
||||
) -> ProviderSpec:
|
||||
"""Create a dynamic ProviderSpec for custom user-defined providers."""
|
||||
normalized = to_snake(name.replace("-", "_"))
|
||||
strip_prefixes = tuple(dict.fromkeys((name, normalized)))
|
||||
@@ -737,7 +742,7 @@ def create_dynamic_spec(name: str, *, thinking_style: str = "") -> ProviderSpec:
|
||||
name=normalized,
|
||||
keywords=(),
|
||||
env_key="",
|
||||
display_name=name.title(),
|
||||
display_name=display_name or name.replace("-", " ").replace("_", " ").title(),
|
||||
backend="openai_compat",
|
||||
is_direct=True,
|
||||
strip_model_prefixes=strip_prefixes,
|
||||
|
||||
+555
-61
@@ -6,6 +6,8 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -215,6 +217,164 @@ def _query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None
|
||||
return _query_first(query, camel) if value is None else value
|
||||
|
||||
|
||||
def _query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
||||
return snake in query or camel in query
|
||||
|
||||
|
||||
def _provider_json_setting(
|
||||
query: QueryParams,
|
||||
snake: str,
|
||||
camel: str,
|
||||
) -> dict[str, Any] | None:
|
||||
raw = (_query_first_alias(query, snake, camel) or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WebUISettingsError(f"{snake} must be a JSON object") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise WebUISettingsError(f"{snake} must be a JSON object")
|
||||
return value or None
|
||||
|
||||
|
||||
_REDACTED_PROVIDER_SECRET = "••••••••"
|
||||
_PROVIDER_STRUCTURED_FIELDS = ("extra_headers", "extra_body", "extra_query")
|
||||
_PROVIDER_SECRET_KEYS = frozenset({
|
||||
"auth",
|
||||
"authentication",
|
||||
"authorization",
|
||||
"bearer",
|
||||
"cookie",
|
||||
"credential",
|
||||
"credentials",
|
||||
"hmac",
|
||||
"key",
|
||||
"passphrase",
|
||||
"passwd",
|
||||
"proxyauthorization",
|
||||
"setcookie",
|
||||
"sig",
|
||||
"signature",
|
||||
})
|
||||
_PROVIDER_SECRET_KEY_SUFFIXES = (
|
||||
"accesskey",
|
||||
"apikey",
|
||||
"encryptionkey",
|
||||
"password",
|
||||
"privatekey",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"signingkey",
|
||||
"subscriptionkey",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
def _provider_setting_key_is_secret(key: str) -> bool:
|
||||
compact = re.sub(r"[^a-z0-9]", "", key.lower())
|
||||
return compact in _PROVIDER_SECRET_KEYS or compact.endswith(_PROVIDER_SECRET_KEY_SUFFIXES)
|
||||
|
||||
|
||||
def _redact_provider_secret_values(value: Any, *, secret: bool = False) -> Any:
|
||||
if secret and value not in (None, ""):
|
||||
return _REDACTED_PROVIDER_SECRET
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _redact_provider_secret_values(
|
||||
item,
|
||||
secret=_provider_setting_key_is_secret(key),
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_redact_provider_secret_values(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _restore_redacted_provider_secret_values(
|
||||
submitted: Any,
|
||||
current: Any,
|
||||
*,
|
||||
secret: bool = False,
|
||||
) -> Any:
|
||||
if secret and submitted == _REDACTED_PROVIDER_SECRET:
|
||||
return current
|
||||
if isinstance(submitted, dict):
|
||||
current_mapping = current if isinstance(current, dict) else {}
|
||||
return {
|
||||
key: _restore_redacted_provider_secret_values(
|
||||
item,
|
||||
current_mapping.get(key),
|
||||
secret=_provider_setting_key_is_secret(key),
|
||||
)
|
||||
for key, item in submitted.items()
|
||||
}
|
||||
if isinstance(submitted, list):
|
||||
current_items = current if isinstance(current, list) else []
|
||||
return [
|
||||
_restore_redacted_provider_secret_values(
|
||||
item,
|
||||
current_items[index] if index < len(current_items) else None,
|
||||
)
|
||||
for index, item in enumerate(submitted)
|
||||
]
|
||||
return submitted
|
||||
|
||||
|
||||
def _provider_config_updates(query: QueryParams) -> dict[str, Any]:
|
||||
updates: dict[str, Any] = {}
|
||||
string_fields = (
|
||||
("api_key", "apiKey"),
|
||||
("api_base", "apiBase"),
|
||||
("api_type", "apiType"),
|
||||
("proxy", "proxy"),
|
||||
("thinking_style", "thinkingStyle"),
|
||||
("region", "region"),
|
||||
("profile", "profile"),
|
||||
("display_name", "displayName"),
|
||||
)
|
||||
for snake, camel in string_fields:
|
||||
if _query_has_alias(query, snake, camel):
|
||||
value = (_query_first_alias(query, snake, camel) or "").strip()
|
||||
updates[snake] = value or ("auto" if snake == "api_type" else None)
|
||||
|
||||
for snake, camel in (
|
||||
("extra_headers", "extraHeaders"),
|
||||
("extra_body", "extraBody"),
|
||||
("extra_query", "extraQuery"),
|
||||
):
|
||||
if _query_has_alias(query, snake, camel):
|
||||
updates[snake] = _provider_json_setting(query, snake, camel)
|
||||
return updates
|
||||
|
||||
|
||||
def _validated_provider_config(
|
||||
provider_config: ProviderConfig | None,
|
||||
updates: dict[str, Any],
|
||||
) -> ProviderConfig:
|
||||
config_type = type(provider_config) if provider_config is not None else ProviderConfig
|
||||
values = provider_config.model_dump(mode="python") if provider_config is not None else {}
|
||||
if provider_config is not None:
|
||||
for field in _PROVIDER_STRUCTURED_FIELDS:
|
||||
if field in updates:
|
||||
updates[field] = _restore_redacted_provider_secret_values(
|
||||
updates[field],
|
||||
getattr(provider_config, field),
|
||||
)
|
||||
values.update(updates)
|
||||
try:
|
||||
return config_type.model_validate(values)
|
||||
except ValueError as exc:
|
||||
errors = getattr(exc, "errors", lambda: [])()
|
||||
if errors:
|
||||
error = errors[0]
|
||||
field = ".".join(str(part) for part in error.get("loc", ()))
|
||||
message = str(error.get("msg", "invalid value"))
|
||||
raise WebUISettingsError(f"{field}: {message}" if field else message) from exc
|
||||
raise WebUISettingsError(str(exc)) from exc
|
||||
|
||||
|
||||
def _mask_secret_hint(secret: str | None) -> str | None:
|
||||
if not secret:
|
||||
return None
|
||||
@@ -377,19 +537,49 @@ def _resolve_settings_provider(
|
||||
normalized = provider_name.replace("-", "_")
|
||||
for extra_name, provider_config in _dynamic_provider_items(config):
|
||||
if provider_name == extra_name or normalized == extra_name.replace("-", "_"):
|
||||
return create_dynamic_spec(extra_name, thinking_style=(provider_config.thinking_style or "")), extra_name, provider_config
|
||||
return (
|
||||
create_dynamic_spec(
|
||||
extra_name,
|
||||
display_name=provider_config.display_name or "",
|
||||
thinking_style=provider_config.thinking_style or "",
|
||||
),
|
||||
extra_name,
|
||||
provider_config,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _provider_advanced_field_names(name: str, spec: Any) -> list[str]:
|
||||
fields: list[str] = []
|
||||
if spec.backend in {"openai_compat", "anthropic"}:
|
||||
fields.append("extra_headers")
|
||||
if spec.backend in {"openai_compat", "bedrock", "openai_codex", "xai_grok"}:
|
||||
fields.append("extra_body")
|
||||
if spec.backend == "openai_compat":
|
||||
fields.extend(("extra_query", "proxy"))
|
||||
if spec.name in _OAUTH_PROXY_PROVIDERS and "proxy" not in fields:
|
||||
fields.append("proxy")
|
||||
if spec.name == "openai":
|
||||
fields.append("api_type")
|
||||
if spec.backend == "bedrock":
|
||||
fields.extend(("region", "profile"))
|
||||
if find_by_name(name) is None:
|
||||
fields.append("thinking_style")
|
||||
return fields
|
||||
|
||||
|
||||
def _provider_settings_row(
|
||||
name: str,
|
||||
spec: Any,
|
||||
provider_config: ProviderConfig,
|
||||
) -> dict[str, Any]:
|
||||
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
|
||||
is_custom = find_by_name(name) is None
|
||||
|
||||
row = {
|
||||
"name": name,
|
||||
"label": spec.label,
|
||||
"is_custom": is_custom,
|
||||
"configured": (
|
||||
bool(oauth_status["configured"])
|
||||
if oauth_status is not None
|
||||
@@ -402,13 +592,19 @@ def _provider_settings_row(
|
||||
"default_api_base": spec.default_api_base or None,
|
||||
"model_selectable": not spec.is_transcription_only,
|
||||
"model_catalog": _model_catalog_kind(spec),
|
||||
"advanced_fields": _provider_advanced_field_names(name, spec),
|
||||
"extra_headers": _redact_provider_secret_values(provider_config.extra_headers),
|
||||
"extra_body": _redact_provider_secret_values(provider_config.extra_body),
|
||||
"extra_query": _redact_provider_secret_values(provider_config.extra_query),
|
||||
"thinking_style": provider_config.thinking_style,
|
||||
"region": getattr(provider_config, "region", None),
|
||||
"profile": getattr(provider_config, "profile", None),
|
||||
"proxy": provider_config.proxy,
|
||||
}
|
||||
if oauth_status is not None:
|
||||
row["oauth_account"] = oauth_status["account"]
|
||||
row["oauth_expires_at"] = oauth_status["expires_at"]
|
||||
row["oauth_login_supported"] = oauth_status["login_supported"]
|
||||
if spec.name in _OAUTH_PROXY_PROVIDERS:
|
||||
row["proxy"] = provider_config.proxy
|
||||
if spec.name == "openai":
|
||||
row["api_type"] = provider_config.api_type
|
||||
return row
|
||||
@@ -675,6 +871,30 @@ def _parse_context_window_tokens(value: str | None) -> int | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_positive_int(value: str | None, field: str) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
raise WebUISettingsError(f"{field} must be an integer") from None
|
||||
if parsed <= 0:
|
||||
raise WebUISettingsError(f"{field} must be greater than zero")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_temperature(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except ValueError:
|
||||
raise WebUISettingsError("temperature must be a number") from None
|
||||
if not math.isfinite(parsed) or parsed < 0 or parsed > 2:
|
||||
raise WebUISettingsError("temperature must be between 0 and 2")
|
||||
return parsed
|
||||
|
||||
|
||||
def _model_configuration_slug(label: str) -> str:
|
||||
normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower())
|
||||
normalized = normalized.strip("-_")
|
||||
@@ -687,6 +907,75 @@ def _model_configuration_slug(label: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _custom_provider_key(config: Any, display_name: str) -> str:
|
||||
slug = _MODEL_CONFIGURATION_SLUG_RE.sub("-", display_name.strip().lower()).strip("-_")
|
||||
base = f"custom-{slug or 'provider'}"
|
||||
if len(base) > 56:
|
||||
base = base[:56].rstrip("-_")
|
||||
existing = {
|
||||
name.replace("_", "-").lower()
|
||||
for name, _provider_config in _dynamic_provider_items(config)
|
||||
}
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate.replace("_", "-").lower() in existing or find_by_name(candidate):
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _provider_display_name_exists(
|
||||
config: Any,
|
||||
display_name: str,
|
||||
*,
|
||||
exclude_key: str | None = None,
|
||||
) -> bool:
|
||||
normalized = display_name.strip().casefold()
|
||||
if any(spec.label.strip().casefold() == normalized for spec in PROVIDERS):
|
||||
return True
|
||||
for provider_key, provider_config in _dynamic_provider_items(config):
|
||||
if provider_key == exclude_key:
|
||||
continue
|
||||
label = (
|
||||
provider_config.display_name
|
||||
or provider_key.replace("-", " ").replace("_", " ").title()
|
||||
)
|
||||
if label.strip().casefold() == normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _unique_model_configuration_name(config: Any, label: str) -> str:
|
||||
"""Return a stable, unused preset name for a migrated model configuration."""
|
||||
try:
|
||||
base = _model_configuration_slug(label)
|
||||
except WebUISettingsError:
|
||||
base = "model"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in config.model_presets:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _model_configuration_label(model: str) -> str:
|
||||
return model.rsplit("/", 1)[-1] or model
|
||||
|
||||
|
||||
def _model_call_order_state(config: Any) -> tuple[list[str], bool]:
|
||||
defaults = config.agents.defaults
|
||||
primary = defaults.model_preset
|
||||
if not primary or primary == "default" or primary not in config.model_presets:
|
||||
return [], False
|
||||
order = [primary]
|
||||
for fallback in defaults.fallback_models:
|
||||
if not isinstance(fallback, str):
|
||||
return [], False
|
||||
order.append(fallback)
|
||||
return order, True
|
||||
|
||||
|
||||
def _validate_configured_provider(config: Any, provider: str) -> None:
|
||||
if provider == "auto":
|
||||
return
|
||||
@@ -817,7 +1106,11 @@ def settings_payload(
|
||||
providers.append(
|
||||
_provider_settings_row(
|
||||
provider_key,
|
||||
create_dynamic_spec(provider_key, thinking_style=(provider_config.thinking_style or "")),
|
||||
create_dynamic_spec(
|
||||
provider_key,
|
||||
display_name=provider_config.display_name or "",
|
||||
thinking_style=provider_config.thinking_style or "",
|
||||
),
|
||||
provider_config,
|
||||
)
|
||||
)
|
||||
@@ -856,11 +1149,23 @@ def settings_payload(
|
||||
"temperature": defaults.temperature,
|
||||
"reasoning_effort": defaults.reasoning_effort,
|
||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||
defaults.provider, defaults.model
|
||||
config.get_provider_name(
|
||||
defaults.model,
|
||||
preset=config.resolve_default_preset(),
|
||||
)
|
||||
or defaults.provider,
|
||||
defaults.model,
|
||||
),
|
||||
}
|
||||
]
|
||||
for name, preset in config.model_presets.items():
|
||||
resolved_preset_provider = (
|
||||
config.get_provider_name(
|
||||
preset.model,
|
||||
preset=preset,
|
||||
)
|
||||
or preset.provider
|
||||
)
|
||||
model_presets.append(
|
||||
{
|
||||
"name": name,
|
||||
@@ -869,20 +1174,18 @@ def settings_payload(
|
||||
"is_default": False,
|
||||
"model": preset.model,
|
||||
"provider": preset.provider,
|
||||
"resolved_provider": config.get_provider_name(
|
||||
preset.model,
|
||||
preset=preset,
|
||||
),
|
||||
"resolved_provider": resolved_preset_provider,
|
||||
"max_tokens": preset.max_tokens,
|
||||
"context_window_tokens": preset.context_window_tokens,
|
||||
"temperature": preset.temperature,
|
||||
"reasoning_effort": preset.reasoning_effort,
|
||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||
preset.provider, preset.model
|
||||
resolved_preset_provider, preset.model
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
model_call_order, model_call_order_editable = _model_call_order_state(config)
|
||||
exec_config = config.tools.exec
|
||||
sandbox_status = workspace_sandbox_status(
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
@@ -905,6 +1208,8 @@ def settings_payload(
|
||||
"tool_hint_max_length": defaults.tool_hint_max_length,
|
||||
},
|
||||
"model_presets": model_presets,
|
||||
"model_call_order": model_call_order,
|
||||
"model_call_order_editable": model_call_order_editable,
|
||||
"providers": providers,
|
||||
"web_search": {
|
||||
"provider": search_provider,
|
||||
@@ -1129,19 +1434,38 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
raise WebUISettingsError("configuration already exists", status=409)
|
||||
_validate_configured_provider(config, provider)
|
||||
|
||||
base = config.resolve_default_preset()
|
||||
base = config.resolve_preset()
|
||||
max_tokens = _parse_positive_int(
|
||||
_query_first_alias(query, "max_tokens", "maxTokens"),
|
||||
"max_tokens",
|
||||
)
|
||||
context_window_tokens = _parse_positive_int(
|
||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens"),
|
||||
"context_window_tokens",
|
||||
)
|
||||
temperature = _parse_temperature(_query_first(query, "temperature"))
|
||||
reasoning_effort = base.reasoning_effort
|
||||
if "reasoning_effort" in query or "reasoningEffort" in query:
|
||||
reasoning_effort = (
|
||||
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
||||
).strip() or None
|
||||
config.model_presets[name] = ModelPresetConfig(
|
||||
label=label,
|
||||
model=model,
|
||||
provider=provider,
|
||||
max_tokens=base.max_tokens,
|
||||
context_window_tokens=base.context_window_tokens,
|
||||
temperature=base.temperature,
|
||||
reasoning_effort=base.reasoning_effort,
|
||||
max_tokens=max_tokens if max_tokens is not None else base.max_tokens,
|
||||
context_window_tokens=(
|
||||
context_window_tokens
|
||||
if context_window_tokens is not None
|
||||
else base.context_window_tokens
|
||||
),
|
||||
temperature=temperature if temperature is not None else base.temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
config.agents.defaults.model_preset = name
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
payload = settings_payload()
|
||||
payload["created_model_preset"] = name
|
||||
return payload
|
||||
|
||||
|
||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
@@ -1183,8 +1507,9 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
preset.provider = provider
|
||||
changed = True
|
||||
|
||||
context_window_tokens = _parse_context_window_tokens(
|
||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
||||
context_window_tokens = _parse_positive_int(
|
||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens"),
|
||||
"context_window_tokens",
|
||||
)
|
||||
if (
|
||||
context_window_tokens is not None
|
||||
@@ -1193,15 +1518,193 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
preset.context_window_tokens = context_window_tokens
|
||||
changed = True
|
||||
|
||||
if config.agents.defaults.model_preset != name:
|
||||
config.agents.defaults.model_preset = name
|
||||
max_tokens = _parse_positive_int(
|
||||
_query_first_alias(query, "max_tokens", "maxTokens"),
|
||||
"max_tokens",
|
||||
)
|
||||
if max_tokens is not None and preset.max_tokens != max_tokens:
|
||||
preset.max_tokens = max_tokens
|
||||
changed = True
|
||||
|
||||
temperature = _parse_temperature(_query_first(query, "temperature"))
|
||||
if temperature is not None and preset.temperature != temperature:
|
||||
preset.temperature = temperature
|
||||
changed = True
|
||||
|
||||
if "reasoning_effort" in query or "reasoningEffort" in query:
|
||||
reasoning_effort = (
|
||||
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
||||
).strip() or None
|
||||
if preset.reasoning_effort != reasoning_effort:
|
||||
preset.reasoning_effort = reasoning_effort
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
|
||||
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||
if raw_order is None:
|
||||
raise WebUISettingsError("model call order is required")
|
||||
try:
|
||||
order = json.loads(raw_order)
|
||||
except json.JSONDecodeError:
|
||||
raise WebUISettingsError("model call order must be a JSON array") from None
|
||||
if (
|
||||
not isinstance(order, list)
|
||||
or not order
|
||||
or any(not isinstance(name, str) or not name.strip() for name in order)
|
||||
):
|
||||
raise WebUISettingsError("model call order must contain at least one preset")
|
||||
|
||||
normalized_order = [name.strip() for name in order]
|
||||
config = load_config()
|
||||
_, editable = _model_call_order_state(config)
|
||||
if not editable:
|
||||
raise WebUISettingsError(
|
||||
"convert the existing model configuration to presets first",
|
||||
status=409,
|
||||
)
|
||||
unknown = [name for name in normalized_order if name not in config.model_presets]
|
||||
if unknown:
|
||||
raise WebUISettingsError(f"unknown model preset: {unknown[0]}")
|
||||
|
||||
defaults = config.agents.defaults
|
||||
fallback_models = normalized_order[1:]
|
||||
if (
|
||||
defaults.model_preset != normalized_order[0]
|
||||
or defaults.fallback_models != fallback_models
|
||||
):
|
||||
defaults.model_preset = normalized_order[0]
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
|
||||
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||
"""Materialize legacy primary/inline model settings as named presets."""
|
||||
config = load_config()
|
||||
defaults = config.agents.defaults
|
||||
primary = config.resolve_preset()
|
||||
created: list[str] = []
|
||||
|
||||
if not defaults.model_preset or defaults.model_preset == "default":
|
||||
label = _model_configuration_label(primary.model)
|
||||
name = _unique_model_configuration_name(config, label)
|
||||
config.model_presets[name] = ModelPresetConfig(
|
||||
label=label,
|
||||
model=primary.model,
|
||||
provider=primary.provider,
|
||||
max_tokens=primary.max_tokens,
|
||||
context_window_tokens=primary.context_window_tokens,
|
||||
temperature=primary.temperature,
|
||||
reasoning_effort=primary.reasoning_effort,
|
||||
)
|
||||
defaults.model_preset = name
|
||||
created.append(name)
|
||||
|
||||
fallback_models: list[str] = []
|
||||
for fallback in defaults.fallback_models:
|
||||
if isinstance(fallback, str):
|
||||
fallback_models.append(fallback)
|
||||
continue
|
||||
label = _model_configuration_label(fallback.model)
|
||||
name = _unique_model_configuration_name(config, label)
|
||||
config.model_presets[name] = ModelPresetConfig(
|
||||
label=label,
|
||||
model=fallback.model,
|
||||
provider=fallback.provider,
|
||||
max_tokens=(
|
||||
fallback.max_tokens
|
||||
if fallback.max_tokens is not None
|
||||
else primary.max_tokens
|
||||
),
|
||||
context_window_tokens=(
|
||||
fallback.context_window_tokens
|
||||
if fallback.context_window_tokens is not None
|
||||
else primary.context_window_tokens
|
||||
),
|
||||
temperature=(
|
||||
fallback.temperature
|
||||
if fallback.temperature is not None
|
||||
else primary.temperature
|
||||
),
|
||||
reasoning_effort=fallback.reasoning_effort,
|
||||
)
|
||||
fallback_models.append(name)
|
||||
created.append(name)
|
||||
|
||||
if created:
|
||||
defaults.fallback_models = fallback_models
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
|
||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
if not name or name == "default":
|
||||
raise WebUISettingsError("model configuration is required")
|
||||
|
||||
config = load_config()
|
||||
if name not in config.model_presets:
|
||||
raise WebUISettingsError("unknown model configuration")
|
||||
defaults = config.agents.defaults
|
||||
referenced = defaults.model_preset == name or any(
|
||||
fallback == name for fallback in defaults.fallback_models
|
||||
)
|
||||
if referenced:
|
||||
raise WebUISettingsError(
|
||||
"remove the model preset from the call order first",
|
||||
status=409,
|
||||
)
|
||||
|
||||
del config.model_presets[name]
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
|
||||
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||
if not display_name:
|
||||
raise WebUISettingsError("provider name is required")
|
||||
if len(display_name) > 80:
|
||||
raise WebUISettingsError("provider name must be 80 characters or fewer")
|
||||
updates = _provider_config_updates(query)
|
||||
allowed = {
|
||||
"api_key",
|
||||
"api_base",
|
||||
"proxy",
|
||||
"extra_headers",
|
||||
"extra_body",
|
||||
"extra_query",
|
||||
"thinking_style",
|
||||
"display_name",
|
||||
}
|
||||
unsupported = set(updates) - allowed
|
||||
if unsupported:
|
||||
field = sorted(unsupported)[0]
|
||||
raise WebUISettingsError(f"{field} is not supported for a custom provider")
|
||||
api_base = str(updates.get("api_base") or "")
|
||||
if not api_base:
|
||||
raise WebUISettingsError("API base is required")
|
||||
|
||||
config = load_config()
|
||||
if _provider_display_name_exists(config, display_name):
|
||||
raise WebUISettingsError("provider already exists", status=409)
|
||||
|
||||
provider_key = _custom_provider_key(config, display_name)
|
||||
updates["display_name"] = display_name
|
||||
updates["api_type"] = "auto"
|
||||
provider_config = _validated_provider_config(None, updates)
|
||||
setattr(config.providers, provider_key, provider_config)
|
||||
save_config(config)
|
||||
payload = settings_payload()
|
||||
payload["created_provider"] = provider_key
|
||||
return payload
|
||||
|
||||
|
||||
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
@@ -1212,52 +1715,43 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
spec, provider_key, provider_config = resolved_provider
|
||||
updates = _provider_config_updates(query)
|
||||
if not spec.is_oauth and spec.name != "openai":
|
||||
# Preserve the legacy settings API contract: api_type only applies to
|
||||
# OpenAI, and is ignored when older clients send it for another provider.
|
||||
updates.pop("api_type", None)
|
||||
if spec.is_oauth:
|
||||
if spec.name not in _OAUTH_PROXY_PROVIDERS:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
if any(
|
||||
key in query
|
||||
for key in ("api_key", "apiKey", "api_base", "apiBase", "api_type")
|
||||
):
|
||||
raise WebUISettingsError("OAuth provider only supports proxy settings")
|
||||
unsupported = set(updates) - {"proxy", "extra_body"}
|
||||
if unsupported:
|
||||
raise WebUISettingsError("OAuth provider only supports proxy and extra_body settings")
|
||||
else:
|
||||
allowed = {
|
||||
"api_key",
|
||||
"api_base",
|
||||
*_provider_advanced_field_names(provider_key, spec),
|
||||
}
|
||||
if find_by_name(provider_key) is None:
|
||||
allowed.add("display_name")
|
||||
unsupported = set(updates) - allowed
|
||||
if unsupported:
|
||||
field = sorted(unsupported)[0]
|
||||
raise WebUISettingsError(f"{field} is not supported for this provider")
|
||||
|
||||
changed = False
|
||||
if "proxy" in query:
|
||||
proxy = (_query_first(query, "proxy") or "").strip() or None
|
||||
if provider_config.proxy != proxy:
|
||||
provider_config.proxy = proxy
|
||||
changed = True
|
||||
if changed:
|
||||
save_config(config)
|
||||
return settings_payload()
|
||||
|
||||
changed = False
|
||||
if "api_key" in query or "apiKey" in query:
|
||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||
api_key = (api_key or "").strip() or None
|
||||
if provider_config.api_key != api_key:
|
||||
provider_config.api_key = api_key
|
||||
changed = True
|
||||
|
||||
if "api_base" in query or "apiBase" in query:
|
||||
api_base = _query_first_alias(query, "api_base", "apiBase")
|
||||
api_base = (api_base or "").strip() or None
|
||||
if provider_config.api_base != api_base:
|
||||
provider_config.api_base = api_base
|
||||
changed = True
|
||||
|
||||
if "api_type" in query:
|
||||
if spec.name == "openai":
|
||||
api_type = (_query_first(query, "api_type") or "").strip()
|
||||
try:
|
||||
parsed_api_type = type(provider_config)(api_type=api_type).api_type
|
||||
except Exception:
|
||||
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None
|
||||
if provider_config.api_type != parsed_api_type:
|
||||
provider_config.api_type = parsed_api_type
|
||||
changed = True
|
||||
if "display_name" in updates:
|
||||
display_name = str(updates["display_name"] or "")
|
||||
if not display_name:
|
||||
raise WebUISettingsError("provider name is required")
|
||||
if len(display_name) > 80:
|
||||
raise WebUISettingsError("provider name must be 80 characters or fewer")
|
||||
if _provider_display_name_exists(config, display_name, exclude_key=provider_key):
|
||||
raise WebUISettingsError("provider already exists", status=409)
|
||||
|
||||
updated_provider_config = _validated_provider_config(provider_config, updates)
|
||||
changed = updated_provider_config != provider_config
|
||||
if changed:
|
||||
setattr(config.providers, provider_key, updated_provider_config)
|
||||
save_config(config)
|
||||
image_config = config.tools.image_generation
|
||||
restart_required = (
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
@@ -51,15 +52,19 @@ from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
complete_oauth_provider,
|
||||
create_model_configuration,
|
||||
create_provider_settings,
|
||||
decorate_settings_payload,
|
||||
delete_model_configuration,
|
||||
login_oauth_provider,
|
||||
logout_oauth_provider,
|
||||
migrate_model_configurations,
|
||||
provider_models_payload,
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_image_generation_settings,
|
||||
update_model_call_order,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
@@ -72,6 +77,8 @@ QueryParams = dict[str, list[str]]
|
||||
|
||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||
@@ -145,8 +152,16 @@ class WebUISettingsRouter:
|
||||
return self._handle_settings_model_configuration_create(request)
|
||||
if path == "/api/settings/model-configurations/update":
|
||||
return self._handle_settings_model_configuration_update(request)
|
||||
if path == "/api/settings/model-configurations/delete":
|
||||
return self._handle_settings_model_configuration_delete(request)
|
||||
if path == "/api/settings/model-configurations/migrate":
|
||||
return self._handle_settings_model_configurations_migrate(request)
|
||||
if path == "/api/settings/model-call-order/update":
|
||||
return self._handle_settings_model_call_order_update(request)
|
||||
if path == "/api/settings/provider/update":
|
||||
return await self._handle_settings_provider_update(request)
|
||||
if path == "/api/settings/provider/create":
|
||||
return self._handle_settings_provider_create(request)
|
||||
if path == "/api/settings/provider-models":
|
||||
return await self._handle_settings_provider_models(request)
|
||||
if path == "/api/settings/provider/oauth-login":
|
||||
@@ -269,6 +284,36 @@ class WebUISettingsRouter:
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||
query = self._query(request)
|
||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||
if not raw:
|
||||
return query
|
||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||
raise WebUISettingsError("provider settings payload is too large")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
try:
|
||||
payload = json.loads(unquote(raw))
|
||||
except json.JSONDecodeError:
|
||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||
|
||||
merged = {key: list(values) for key, values in query.items()}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not key:
|
||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
elif value is None:
|
||||
text = ""
|
||||
else:
|
||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
merged[key] = [text]
|
||||
return merged
|
||||
|
||||
def _handle_settings(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
@@ -353,16 +398,52 @@ class WebUISettingsRouter:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configuration_delete(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = delete_model_configuration(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_configurations_migrate(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = migrate_model_configurations(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
def _handle_settings_model_call_order_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_model_call_order(self._query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
async def _handle_settings_provider_update(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = update_provider_settings(self._query(request))
|
||||
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
payload = await self._apply_image_generation_runtime_change(payload)
|
||||
return self._json_response(self._with_restart_state(payload, section="image"))
|
||||
|
||||
def _handle_settings_provider_create(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
try:
|
||||
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||
except WebUISettingsError as e:
|
||||
return self._error_response(e.status, e.message)
|
||||
return self._json_response(self._with_restart_state(payload))
|
||||
|
||||
async def _handle_settings_provider_models(self, request: WsRequest) -> Response:
|
||||
if not self._authorized(request):
|
||||
return self._unauthorized()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,39 @@
|
||||
"configurationName": "Configuration name",
|
||||
"configurationNameHelp": "Rename this saved model configuration.",
|
||||
"configurationNamePlaceholder": "Fast writing",
|
||||
"callOrder": "Model call order",
|
||||
"primary": "Primary",
|
||||
"fallbackNumber": "Fallback {{number}}",
|
||||
"addToOrder": "Enable preset",
|
||||
"newPreset": "New model preset",
|
||||
"newPresetHelp": "Save a reusable model and its generation settings.",
|
||||
"presets": "Model presets",
|
||||
"editPreset": "Edit preset",
|
||||
"presetName": "Preset name",
|
||||
"presetNameHelp": "A short name used throughout model settings.",
|
||||
"presetNamePlaceholder": "Fast writing",
|
||||
"advancedOptions": "Advanced options",
|
||||
"advancedSummary": "Context {{context}} · Max {{max}} tokens",
|
||||
"maxTokens": "Max output tokens",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "Reasoning effort",
|
||||
"convertTitle": "Convert the current model setup",
|
||||
"convertHelp": "Turn the existing primary and fallback models into presets so their order can be managed here.",
|
||||
"converting": "Converting...",
|
||||
"convertAction": "Convert to presets",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"removeFromOrder": "Disable preset",
|
||||
"inCallOrder": "In call order",
|
||||
"disabled": "Disabled",
|
||||
"noPresets": "No model presets yet",
|
||||
"noPresetsHelp": "Create a preset, then add it to the call order.",
|
||||
"removeBeforeDelete": "Remove this preset from the call order before deleting it.",
|
||||
"providerSetupRequired": "Provider setup required",
|
||||
"configureProviderBeforeSaving": "Configure this provider before saving the preset.",
|
||||
"deletePresetTitle": "Delete model preset?",
|
||||
"deletePresetHelp": "This removes the preset “{{name}}”. Provider credentials are not affected.",
|
||||
"searchModels": "Search or type model ID",
|
||||
"useCustomModel": "Use",
|
||||
"loadingModels": "Loading models...",
|
||||
@@ -180,7 +213,7 @@
|
||||
"theme": "Switch between light and dark appearance.",
|
||||
"language": "Choose the language used by the WebUI.",
|
||||
"provider": "Select the provider that should serve new model requests.",
|
||||
"model": "Set the default model name used by nanobot.",
|
||||
"model": "Choose the model used by this preset.",
|
||||
"configPath": "The gateway configuration file currently in use.",
|
||||
"currentModel": "Used for new replies.",
|
||||
"selectedModelProvider": "Set by the selected model.",
|
||||
@@ -331,6 +364,7 @@
|
||||
"comfortable": "Comfortable",
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
"default": "Default",
|
||||
"expanded": "Expanded",
|
||||
"summary": "Summary",
|
||||
"diff": "Diff",
|
||||
@@ -364,7 +398,11 @@
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saving": "Saving",
|
||||
"saveOrder": "Save order",
|
||||
"savePreset": "Save preset",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"cancel": "Cancel",
|
||||
"open": "Open",
|
||||
"export": "Export",
|
||||
@@ -372,7 +410,7 @@
|
||||
"exporting": "Exporting..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
|
||||
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be used in model presets.",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured",
|
||||
"configuredSection": "Configured",
|
||||
@@ -449,9 +487,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Model providers",
|
||||
"searchPlaceholder": "Search providers",
|
||||
"noMatches": "No providers match this search.",
|
||||
"saveProvider": "Save provider"
|
||||
"addOwnProvider": "Add your own model provider",
|
||||
"customProvider": "Custom provider",
|
||||
"customProviderName": "Provider name",
|
||||
"customProviderNamePlaceholder": "My model provider",
|
||||
"saveProvider": "Save provider",
|
||||
"advancedOptions": "Advanced options",
|
||||
"saveAdvancedBeforeSignIn": "Save advanced changes before signing in.",
|
||||
"apiType": "API type",
|
||||
"proxy": "Network proxy",
|
||||
"thinkingStyle": "Thinking style",
|
||||
"region": "Region",
|
||||
"profile": "Profile",
|
||||
"extraHeaders": "Extra headers",
|
||||
"extraBody": "Extra body",
|
||||
"extraQuery": "Extra query"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
|
||||
@@ -721,7 +774,7 @@
|
||||
"signInHelp": "Sign in from this device; no API key is stored in config.",
|
||||
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
||||
"signInRequired": "Sign in required",
|
||||
"signInBeforeSaving": "Sign in before saving this OAuth provider as the active model provider.",
|
||||
"signInBeforeSaving": "Sign in before saving this provider in the preset.",
|
||||
"signedIn": "Signed in",
|
||||
"notSignedIn": "Not signed in",
|
||||
"proxyLabel": "Network proxy",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "Cambia entre apariencia clara y oscura.",
|
||||
"language": "Elige el idioma usado por WebUI.",
|
||||
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||
"model": "Define el nombre de modelo predeterminado de nanobot.",
|
||||
"model": "Elige el modelo que usa este preajuste.",
|
||||
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "Compacto",
|
||||
"auto": "Automático",
|
||||
"expanded": "Expandido",
|
||||
"default": "Predeterminado",
|
||||
"summary": "Resumen",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff contraído",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando",
|
||||
"saveOrder": "Guardar orden",
|
||||
"savePreset": "Guardar preajuste",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando...",
|
||||
"edit": "Editar",
|
||||
"cancel": "Cancelar",
|
||||
"open": "Abrir",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "Exportando..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
|
||||
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual y solo los proveedores configurados pueden usarse en los preajustes de modelo.",
|
||||
"configured": "Configurado",
|
||||
"notConfigured": "Sin configurar",
|
||||
"configuredSection": "Configurados",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Proveedores de modelos",
|
||||
"searchPlaceholder": "Buscar proveedores",
|
||||
"noMatches": "Ningún proveedor coincide con esta búsqueda.",
|
||||
"saveProvider": "Guardar proveedor"
|
||||
"addOwnProvider": "Añadir tu propio proveedor de modelos",
|
||||
"customProvider": "Proveedor personalizado",
|
||||
"customProviderName": "Nombre del proveedor",
|
||||
"customProviderNamePlaceholder": "Mi proveedor de modelos",
|
||||
"saveProvider": "Guardar proveedor",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"saveAdvancedBeforeSignIn": "Guarda los cambios avanzados antes de iniciar sesión.",
|
||||
"apiType": "Tipo de API",
|
||||
"proxy": "Proxy de red",
|
||||
"thinkingStyle": "Estilo de razonamiento",
|
||||
"region": "Región",
|
||||
"profile": "Perfil",
|
||||
"extraHeaders": "Cabeceras adicionales",
|
||||
"extraBody": "Cuerpo adicional",
|
||||
"extraQuery": "Parámetros de consulta adicionales"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Seleccionar proveedor",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "Nombre de configuración",
|
||||
"configurationNameHelp": "Renombra esta configuración de modelo guardada.",
|
||||
"configurationNamePlaceholder": "Escritura rápida",
|
||||
"callOrder": "Orden de llamada de modelos",
|
||||
"primary": "Principal",
|
||||
"fallbackNumber": "Alternativa {{number}}",
|
||||
"addToOrder": "Activar preajuste",
|
||||
"newPreset": "Nuevo preajuste de modelo",
|
||||
"newPresetHelp": "Guarda un modelo reutilizable y sus ajustes de generación.",
|
||||
"presets": "Preajustes de modelo",
|
||||
"editPreset": "Editar preajuste",
|
||||
"presetName": "Nombre del preajuste",
|
||||
"presetNameHelp": "Un nombre corto para usar en los ajustes de modelos.",
|
||||
"presetNamePlaceholder": "Escritura rápida",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
|
||||
"maxTokens": "Máx. tokens de salida",
|
||||
"temperature": "Temperatura",
|
||||
"reasoningEffort": "Esfuerzo de razonamiento",
|
||||
"convertTitle": "Convertir la configuración de modelos actual",
|
||||
"convertHelp": "Convierte los modelos principal y alternativos existentes en preajustes para administrar aquí su orden.",
|
||||
"converting": "Convirtiendo...",
|
||||
"convertAction": "Convertir en preajustes",
|
||||
"dragToReorder": "Arrastrar para reordenar",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"removeFromOrder": "Desactivar preajuste",
|
||||
"inCallOrder": "En el orden de llamada",
|
||||
"disabled": "Desactivado",
|
||||
"noPresets": "Aún no hay preajustes de modelo",
|
||||
"noPresetsHelp": "Crea un preajuste y añádelo al orden de llamada.",
|
||||
"removeBeforeDelete": "Quita este preajuste del orden de llamada antes de eliminarlo.",
|
||||
"providerSetupRequired": "El proveedor requiere configuración",
|
||||
"configureProviderBeforeSaving": "Configura este proveedor antes de guardar el preajuste.",
|
||||
"deletePresetTitle": "¿Eliminar el preajuste de modelo?",
|
||||
"deletePresetHelp": "Se eliminará el preajuste «{{name}}». Las credenciales del proveedor no cambiarán.",
|
||||
"searchModels": "Buscar o escribir ID de modelo",
|
||||
"useCustomModel": "Usar",
|
||||
"loadingModels": "Cargando modelos...",
|
||||
@@ -708,7 +761,7 @@
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
|
||||
"signInRequired": "Inicio de sesión requerido",
|
||||
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
|
||||
"signInBeforeSaving": "Inicia sesión en este proveedor antes de guardar el preajuste.",
|
||||
"signedIn": "Sesión iniciada",
|
||||
"notSignedIn": "Sin sesión",
|
||||
"proxyLabel": "Proxy de red",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "Basculer entre l’apparence claire et sombre.",
|
||||
"language": "Choisissez la langue utilisée par WebUI.",
|
||||
"provider": "Sélectionnez le fournisseur à utiliser pour les nouvelles requêtes de modèle.",
|
||||
"model": "Définissez le nom du modèle utilisé par défaut par nanobot.",
|
||||
"model": "Choisissez le modèle utilisé par ce préréglage.",
|
||||
"configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
|
||||
"selectedPreset": "Les préréglages nommés sont en lecture seule ici ; modifiez-les dans config.json.",
|
||||
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "Compacte",
|
||||
"auto": "Automatique",
|
||||
"expanded": "Développé",
|
||||
"default": "Par défaut",
|
||||
"summary": "Résumé",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff replié",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement",
|
||||
"saveOrder": "Enregistrer l’ordre",
|
||||
"savePreset": "Enregistrer le préréglage",
|
||||
"delete": "Supprimer",
|
||||
"deleting": "Suppression...",
|
||||
"edit": "Modifier",
|
||||
"cancel": "Annuler",
|
||||
"open": "Ouvrir",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "Exportation..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
|
||||
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle et seuls les fournisseurs configurés peuvent être utilisés dans les préréglages de modèle.",
|
||||
"configured": "Configuré",
|
||||
"notConfigured": "Non configuré",
|
||||
"configuredSection": "Configurés",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Fournisseurs de modèles",
|
||||
"searchPlaceholder": "Rechercher des fournisseurs",
|
||||
"noMatches": "Aucun fournisseur ne correspond.",
|
||||
"saveProvider": "Enregistrer le fournisseur"
|
||||
"addOwnProvider": "Ajouter votre propre fournisseur de modèles",
|
||||
"customProvider": "Fournisseur personnalisé",
|
||||
"customProviderName": "Nom du fournisseur",
|
||||
"customProviderNamePlaceholder": "Mon fournisseur de modèles",
|
||||
"saveProvider": "Enregistrer le fournisseur",
|
||||
"advancedOptions": "Options avancées",
|
||||
"saveAdvancedBeforeSignIn": "Enregistrez les options avancées avant de vous connecter.",
|
||||
"apiType": "Type d’API",
|
||||
"proxy": "Proxy réseau",
|
||||
"thinkingStyle": "Style de raisonnement",
|
||||
"region": "Région",
|
||||
"profile": "Profil",
|
||||
"extraHeaders": "En-têtes supplémentaires",
|
||||
"extraBody": "Corps supplémentaire",
|
||||
"extraQuery": "Paramètres de requête supplémentaires"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Choisir un fournisseur",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "Nom de la configuration",
|
||||
"configurationNameHelp": "Renomme cette configuration de modèle enregistrée.",
|
||||
"configurationNamePlaceholder": "Rédaction rapide",
|
||||
"callOrder": "Ordre d’appel des modèles",
|
||||
"primary": "Principal",
|
||||
"fallbackNumber": "Secours {{number}}",
|
||||
"addToOrder": "Activer le préréglage",
|
||||
"newPreset": "Nouveau préréglage de modèle",
|
||||
"newPresetHelp": "Enregistrez un modèle réutilisable et ses paramètres de génération.",
|
||||
"presets": "Préréglages de modèle",
|
||||
"editPreset": "Modifier le préréglage",
|
||||
"presetName": "Nom du préréglage",
|
||||
"presetNameHelp": "Un nom court utilisé dans les paramètres des modèles.",
|
||||
"presetNamePlaceholder": "Rédaction rapide",
|
||||
"advancedOptions": "Options avancées",
|
||||
"advancedSummary": "Contexte {{context}} · Max. {{max}} tokens",
|
||||
"maxTokens": "Tokens de sortie max.",
|
||||
"temperature": "Température",
|
||||
"reasoningEffort": "Effort de raisonnement",
|
||||
"convertTitle": "Convertir la configuration de modèles actuelle",
|
||||
"convertHelp": "Convertissez les modèles principal et de secours existants en préréglages afin de gérer leur ordre ici.",
|
||||
"converting": "Conversion...",
|
||||
"convertAction": "Convertir en préréglages",
|
||||
"dragToReorder": "Faire glisser pour réorganiser",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"removeFromOrder": "Désactiver le préréglage",
|
||||
"inCallOrder": "Dans l’ordre d’appel",
|
||||
"disabled": "Désactivé",
|
||||
"noPresets": "Aucun préréglage de modèle",
|
||||
"noPresetsHelp": "Créez un préréglage, puis ajoutez-le à l’ordre d’appel.",
|
||||
"removeBeforeDelete": "Retirez ce préréglage de l’ordre d’appel avant de le supprimer.",
|
||||
"providerSetupRequired": "Configuration du fournisseur requise",
|
||||
"configureProviderBeforeSaving": "Configurez ce fournisseur avant d’enregistrer le préréglage.",
|
||||
"deletePresetTitle": "Supprimer le préréglage de modèle ?",
|
||||
"deletePresetHelp": "Le préréglage « {{name}} » sera supprimé. Les identifiants du fournisseur ne seront pas modifiés.",
|
||||
"searchModels": "Rechercher ou saisir un ID de modèle",
|
||||
"useCustomModel": "Utiliser",
|
||||
"loadingModels": "Chargement des modèles...",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
||||
"signInRequired": "Connexion requise",
|
||||
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
|
||||
"signInBeforeSaving": "Connectez-vous à ce fournisseur avant d’enregistrer le préréglage.",
|
||||
"signedIn": "Connecté",
|
||||
"notSignedIn": "Non connecté",
|
||||
"proxyLabel": "Proxy réseau",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "Beralih antara tampilan terang dan gelap.",
|
||||
"language": "Pilih bahasa yang digunakan WebUI.",
|
||||
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||
"model": "Define el nombre de modelo predeterminado de nanobot.",
|
||||
"model": "Pilih model yang digunakan oleh preset ini.",
|
||||
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "Ringkas",
|
||||
"auto": "Otomatis",
|
||||
"expanded": "Diperluas",
|
||||
"default": "Default",
|
||||
"summary": "Ringkasan",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff diciutkan",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "Simpan",
|
||||
"saving": "Menyimpan",
|
||||
"saveOrder": "Simpan urutan",
|
||||
"savePreset": "Simpan preset",
|
||||
"delete": "Hapus",
|
||||
"deleting": "Menghapus...",
|
||||
"edit": "Edit",
|
||||
"cancel": "Batal",
|
||||
"open": "Buka",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "Mengekspor..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
|
||||
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini dan hanya provider yang sudah dikonfigurasi yang dapat digunakan dalam preset model.",
|
||||
"configured": "Terkonfigurasi",
|
||||
"notConfigured": "Belum dikonfigurasi",
|
||||
"configuredSection": "Terkonfigurasi",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Penyedia model",
|
||||
"searchPlaceholder": "Cari penyedia",
|
||||
"noMatches": "Tidak ada penyedia yang cocok.",
|
||||
"saveProvider": "Simpan penyedia"
|
||||
"addOwnProvider": "Tambahkan penyedia model Anda sendiri",
|
||||
"customProvider": "Penyedia khusus",
|
||||
"customProviderName": "Nama penyedia",
|
||||
"customProviderNamePlaceholder": "Penyedia model saya",
|
||||
"saveProvider": "Simpan penyedia",
|
||||
"advancedOptions": "Opsi lanjutan",
|
||||
"saveAdvancedBeforeSignIn": "Simpan perubahan lanjutan sebelum masuk.",
|
||||
"apiType": "Jenis API",
|
||||
"proxy": "Proksi jaringan",
|
||||
"thinkingStyle": "Gaya penalaran",
|
||||
"region": "Wilayah",
|
||||
"profile": "Profil",
|
||||
"extraHeaders": "Header tambahan",
|
||||
"extraBody": "Body tambahan",
|
||||
"extraQuery": "Parameter kueri tambahan"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Pilih penyedia",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "Nama konfigurasi",
|
||||
"configurationNameHelp": "Ganti nama konfigurasi model tersimpan ini.",
|
||||
"configurationNamePlaceholder": "Menulis cepat",
|
||||
"callOrder": "Urutan pemanggilan model",
|
||||
"primary": "Utama",
|
||||
"fallbackNumber": "Cadangan {{number}}",
|
||||
"addToOrder": "Aktifkan preset",
|
||||
"newPreset": "Preset model baru",
|
||||
"newPresetHelp": "Simpan model yang dapat digunakan kembali beserta pengaturan generasinya.",
|
||||
"presets": "Preset model",
|
||||
"editPreset": "Edit preset",
|
||||
"presetName": "Nama preset",
|
||||
"presetNameHelp": "Nama singkat yang digunakan di pengaturan model.",
|
||||
"presetNamePlaceholder": "Menulis cepat",
|
||||
"advancedOptions": "Opsi lanjutan",
|
||||
"advancedSummary": "Konteks {{context}} · Maks. {{max}} token",
|
||||
"maxTokens": "Maks. token keluaran",
|
||||
"temperature": "Temperatur",
|
||||
"reasoningEffort": "Upaya penalaran",
|
||||
"convertTitle": "Konversi pengaturan model saat ini",
|
||||
"convertHelp": "Ubah model utama dan cadangan yang ada menjadi preset agar urutannya dapat dikelola di sini.",
|
||||
"converting": "Mengonversi...",
|
||||
"convertAction": "Konversi ke preset",
|
||||
"dragToReorder": "Seret untuk mengurutkan ulang",
|
||||
"moveUp": "Naikkan",
|
||||
"moveDown": "Turunkan",
|
||||
"removeFromOrder": "Nonaktifkan preset",
|
||||
"inCallOrder": "Dalam urutan pemanggilan",
|
||||
"disabled": "Nonaktif",
|
||||
"noPresets": "Belum ada preset model",
|
||||
"noPresetsHelp": "Buat preset, lalu tambahkan ke urutan pemanggilan.",
|
||||
"removeBeforeDelete": "Hapus preset ini dari urutan pemanggilan sebelum menghapusnya.",
|
||||
"providerSetupRequired": "Penyedia perlu dikonfigurasi",
|
||||
"configureProviderBeforeSaving": "Konfigurasikan penyedia ini sebelum menyimpan preset.",
|
||||
"deletePresetTitle": "Hapus preset model?",
|
||||
"deletePresetHelp": "Preset “{{name}}” akan dihapus. Kredensial penyedia tidak terpengaruh.",
|
||||
"searchModels": "Cari atau ketik ID model",
|
||||
"useCustomModel": "Gunakan",
|
||||
"loadingModels": "Memuat model...",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
||||
"signInRequired": "Perlu masuk",
|
||||
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
|
||||
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
|
||||
"signedIn": "Sudah masuk",
|
||||
"notSignedIn": "Belum masuk",
|
||||
"proxyLabel": "Proksi jaringan",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "ライト表示とダーク表示を切り替えます。",
|
||||
"language": "WebUI で使用する言語を選択します。",
|
||||
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
|
||||
"model": "nanobot が既定で使用するモデル名を設定します。",
|
||||
"model": "このプリセットで使用するモデルを選択します。",
|
||||
"configPath": "現在ゲートウェイが使用している設定ファイルです。",
|
||||
"selectedPreset": "名前付きプリセットはここでは読み取り専用です。編集するには config.json を変更してください。",
|
||||
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "コンパクト",
|
||||
"auto": "自動",
|
||||
"expanded": "展開",
|
||||
"default": "デフォルト",
|
||||
"summary": "概要",
|
||||
"diff": "差分",
|
||||
"collapsedDiff": "折りたたみ差分",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"saving": "保存中",
|
||||
"saveOrder": "順序を保存",
|
||||
"savePreset": "プリセットを保存",
|
||||
"delete": "削除",
|
||||
"deleting": "削除中...",
|
||||
"edit": "編集",
|
||||
"cancel": "キャンセル",
|
||||
"open": "開く",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "書き出しています..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
|
||||
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけをモデルプリセットで使用できます。",
|
||||
"configured": "設定済み",
|
||||
"notConfigured": "未設定",
|
||||
"configuredSection": "設定済み",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "モデルプロバイダー",
|
||||
"searchPlaceholder": "プロバイダーを検索",
|
||||
"noMatches": "一致するプロバイダーはありません。",
|
||||
"saveProvider": "プロバイダーを保存"
|
||||
"addOwnProvider": "独自のモデルプロバイダーを追加",
|
||||
"customProvider": "カスタムプロバイダー",
|
||||
"customProviderName": "プロバイダー名",
|
||||
"customProviderNamePlaceholder": "マイモデルプロバイダー",
|
||||
"saveProvider": "プロバイダーを保存",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"saveAdvancedBeforeSignIn": "サインインする前に詳細オプションを保存してください。",
|
||||
"apiType": "API タイプ",
|
||||
"proxy": "ネットワークプロキシ",
|
||||
"thinkingStyle": "思考スタイル",
|
||||
"region": "リージョン",
|
||||
"profile": "プロファイル",
|
||||
"extraHeaders": "追加ヘッダー",
|
||||
"extraBody": "追加ボディ",
|
||||
"extraQuery": "追加クエリ"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "プロバイダーを選択",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "設定名",
|
||||
"configurationNameHelp": "保存済みのモデル設定の名前を変更します。",
|
||||
"configurationNamePlaceholder": "高速執筆",
|
||||
"callOrder": "モデル呼び出し順序",
|
||||
"primary": "プライマリ",
|
||||
"fallbackNumber": "フォールバック {{number}}",
|
||||
"addToOrder": "プリセットを有効化",
|
||||
"newPreset": "新しいモデルプリセット",
|
||||
"newPresetHelp": "再利用できるモデルと生成設定を保存します。",
|
||||
"presets": "モデルプリセット",
|
||||
"editPreset": "プリセットを編集",
|
||||
"presetName": "プリセット名",
|
||||
"presetNameHelp": "モデル設定で使用する短い名前です。",
|
||||
"presetNamePlaceholder": "高速執筆",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"advancedSummary": "コンテキスト {{context}} · 最大 {{max}} トークン",
|
||||
"maxTokens": "最大出力トークン",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "推論の強度",
|
||||
"convertTitle": "現在のモデル設定を変換",
|
||||
"convertHelp": "既存のプライマリモデルとフォールバックモデルをプリセットに変換し、ここで順序を管理できるようにします。",
|
||||
"converting": "変換中...",
|
||||
"convertAction": "プリセットに変換",
|
||||
"dragToReorder": "ドラッグして並べ替え",
|
||||
"moveUp": "上へ移動",
|
||||
"moveDown": "下へ移動",
|
||||
"removeFromOrder": "プリセットを無効化",
|
||||
"inCallOrder": "呼び出し順序に追加済み",
|
||||
"disabled": "無効",
|
||||
"noPresets": "モデルプリセットはまだありません",
|
||||
"noPresetsHelp": "プリセットを作成してから呼び出し順序に追加してください。",
|
||||
"removeBeforeDelete": "削除する前に、このプリセットを呼び出し順序から外してください。",
|
||||
"providerSetupRequired": "プロバイダーの設定が必要です",
|
||||
"configureProviderBeforeSaving": "プリセットを保存する前に、このプロバイダーを設定してください。",
|
||||
"deletePresetTitle": "モデルプリセットを削除しますか?",
|
||||
"deletePresetHelp": "プリセット「{{name}}」を削除します。プロバイダーの認証情報には影響しません。",
|
||||
"searchModels": "モデル ID を検索または入力",
|
||||
"useCustomModel": "使用",
|
||||
"loadingModels": "モデルを読み込んでいます...",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
|
||||
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
||||
"signInRequired": "サインインが必要です",
|
||||
"signInBeforeSaving": "この OAuth プロバイダーをアクティブなモデルプロバイダーとして保存する前にサインインしてください。",
|
||||
"signInBeforeSaving": "プリセットを保存する前に、このプロバイダーへサインインしてください。",
|
||||
"signedIn": "サインイン済み",
|
||||
"notSignedIn": "未サインイン",
|
||||
"proxyLabel": "ネットワークプロキシ",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
|
||||
"language": "WebUI에서 사용할 언어를 선택합니다.",
|
||||
"provider": "새 모델 요청을 처리할 제공자를 선택합니다.",
|
||||
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
|
||||
"model": "이 프리셋에서 사용할 모델을 선택하세요.",
|
||||
"configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
|
||||
"selectedPreset": "이름 있는 프리셋은 여기서 읽기 전용입니다. config.json에서 편집하세요.",
|
||||
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "컴팩트",
|
||||
"auto": "자동",
|
||||
"expanded": "펼침",
|
||||
"default": "기본값",
|
||||
"summary": "요약",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "접힌 diff",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "저장",
|
||||
"saving": "저장 중",
|
||||
"saveOrder": "순서 저장",
|
||||
"savePreset": "프리셋 저장",
|
||||
"delete": "삭제",
|
||||
"deleting": "삭제 중...",
|
||||
"edit": "편집",
|
||||
"cancel": "취소",
|
||||
"open": "열기",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "내보내는 중..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
|
||||
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 모델 프리셋에서 사용할 수 있습니다.",
|
||||
"configured": "설정됨",
|
||||
"notConfigured": "설정 안 됨",
|
||||
"configuredSection": "설정됨",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "모델 제공업체",
|
||||
"searchPlaceholder": "제공자 검색",
|
||||
"noMatches": "일치하는 제공자가 없습니다.",
|
||||
"saveProvider": "제공자 저장"
|
||||
"addOwnProvider": "내 모델 제공업체 추가",
|
||||
"customProvider": "사용자 지정 제공업체",
|
||||
"customProviderName": "제공업체 이름",
|
||||
"customProviderNamePlaceholder": "내 모델 제공업체",
|
||||
"saveProvider": "제공업체 저장",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"saveAdvancedBeforeSignIn": "로그인하기 전에 고급 옵션을 저장하세요.",
|
||||
"apiType": "API 유형",
|
||||
"proxy": "네트워크 프록시",
|
||||
"thinkingStyle": "추론 방식",
|
||||
"region": "리전",
|
||||
"profile": "프로필",
|
||||
"extraHeaders": "추가 헤더",
|
||||
"extraBody": "추가 본문",
|
||||
"extraQuery": "추가 쿼리"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "제공자 선택",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "구성 이름",
|
||||
"configurationNameHelp": "저장된 모델 구성의 이름을 변경합니다.",
|
||||
"configurationNamePlaceholder": "빠른 작성",
|
||||
"callOrder": "모델 호출 순서",
|
||||
"primary": "기본",
|
||||
"fallbackNumber": "대체 {{number}}",
|
||||
"addToOrder": "프리셋 활성화",
|
||||
"newPreset": "새 모델 프리셋",
|
||||
"newPresetHelp": "재사용할 모델과 생성 설정을 저장합니다.",
|
||||
"presets": "모델 프리셋",
|
||||
"editPreset": "프리셋 편집",
|
||||
"presetName": "프리셋 이름",
|
||||
"presetNameHelp": "모델 설정에서 사용할 짧은 이름입니다.",
|
||||
"presetNamePlaceholder": "빠른 작성",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"advancedSummary": "컨텍스트 {{context}} · 최대 {{max}} 토큰",
|
||||
"maxTokens": "최대 출력 토큰",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "추론 강도",
|
||||
"convertTitle": "현재 모델 설정 변환",
|
||||
"convertHelp": "기존 기본 및 대체 모델을 프리셋으로 변환하여 여기서 순서를 관리합니다.",
|
||||
"converting": "변환 중...",
|
||||
"convertAction": "프리셋으로 변환",
|
||||
"dragToReorder": "드래그하여 순서 변경",
|
||||
"moveUp": "위로 이동",
|
||||
"moveDown": "아래로 이동",
|
||||
"removeFromOrder": "프리셋 비활성화",
|
||||
"inCallOrder": "호출 순서에 포함됨",
|
||||
"disabled": "비활성화됨",
|
||||
"noPresets": "아직 모델 프리셋이 없습니다",
|
||||
"noPresetsHelp": "프리셋을 만든 다음 호출 순서에 추가하세요.",
|
||||
"removeBeforeDelete": "삭제하기 전에 이 프리셋을 호출 순서에서 제거하세요.",
|
||||
"providerSetupRequired": "제공자 설정 필요",
|
||||
"configureProviderBeforeSaving": "프리셋을 저장하기 전에 이 제공자를 설정하세요.",
|
||||
"deletePresetTitle": "모델 프리셋을 삭제할까요?",
|
||||
"deletePresetHelp": "프리셋 “{{name}}”을 삭제합니다. 제공자 인증 정보에는 영향을 주지 않습니다.",
|
||||
"searchModels": "모델 ID 검색 또는 입력",
|
||||
"useCustomModel": "사용",
|
||||
"loadingModels": "모델 불러오는 중...",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
|
||||
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
||||
"signInRequired": "로그인이 필요합니다",
|
||||
"signInBeforeSaving": "이 OAuth 제공자를 활성 모델 제공자로 저장하기 전에 로그인하세요.",
|
||||
"signInBeforeSaving": "프리셋을 저장하기 전에 이 제공자에 로그인하세요.",
|
||||
"signedIn": "로그인됨",
|
||||
"notSignedIn": "로그인 안 됨",
|
||||
"proxyLabel": "네트워크 프록시",
|
||||
|
||||
@@ -115,6 +115,39 @@
|
||||
"configurationName": "Nome da configuração",
|
||||
"configurationNameHelp": "Renomeie esta configuração de modelo.",
|
||||
"configurationNamePlaceholder": "Escrita rápida",
|
||||
"callOrder": "Ordem de chamada dos modelos",
|
||||
"primary": "Principal",
|
||||
"fallbackNumber": "Alternativo {{number}}",
|
||||
"addToOrder": "Ativar predefinição",
|
||||
"newPreset": "Nova predefinição de modelo",
|
||||
"newPresetHelp": "Salve um modelo reutilizável e suas configurações de geração.",
|
||||
"presets": "Predefinições de modelo",
|
||||
"editPreset": "Editar predefinição",
|
||||
"presetName": "Nome da predefinição",
|
||||
"presetNameHelp": "Um nome curto usado nas configurações de modelo.",
|
||||
"presetNamePlaceholder": "Escrita rápida",
|
||||
"advancedOptions": "Opções avançadas",
|
||||
"advancedSummary": "Contexto {{context}} · Máx. {{max}} tokens",
|
||||
"maxTokens": "Máx. de tokens de saída",
|
||||
"temperature": "Temperatura",
|
||||
"reasoningEffort": "Esforço de raciocínio",
|
||||
"convertTitle": "Converter a configuração de modelos atual",
|
||||
"convertHelp": "Converta os modelos principal e alternativos existentes em predefinições para gerenciar a ordem aqui.",
|
||||
"converting": "Convertendo...",
|
||||
"convertAction": "Converter em predefinições",
|
||||
"dragToReorder": "Arraste para reordenar",
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo",
|
||||
"removeFromOrder": "Desativar predefinição",
|
||||
"inCallOrder": "Na ordem de chamada",
|
||||
"disabled": "Desabilitado",
|
||||
"noPresets": "Ainda não há predefinições de modelo",
|
||||
"noPresetsHelp": "Crie uma predefinição e adicione-a à ordem de chamada.",
|
||||
"removeBeforeDelete": "Remova esta predefinição da ordem de chamada antes de excluí-la.",
|
||||
"providerSetupRequired": "Configuração do provedor necessária",
|
||||
"configureProviderBeforeSaving": "Configure este provedor antes de salvar a predefinição.",
|
||||
"deletePresetTitle": "Excluir predefinição de modelo?",
|
||||
"deletePresetHelp": "A predefinição “{{name}}” será removida. As credenciais do provedor não serão afetadas.",
|
||||
"searchModels": "Buscar ou digitar ID do modelo",
|
||||
"useCustomModel": "Usar",
|
||||
"loadingModels": "Carregando modelos...",
|
||||
@@ -180,7 +213,7 @@
|
||||
"theme": "Alterna entre aparência clara e escura.",
|
||||
"language": "Escolha o idioma usado pela WebUI.",
|
||||
"provider": "Selecione o provedor que atenderá as novas requisições de modelo.",
|
||||
"model": "Defina o nome do modelo padrão usado pelo nanobot.",
|
||||
"model": "Escolha o modelo usado por esta predefinição.",
|
||||
"configPath": "O arquivo de configuração do gateway em uso.",
|
||||
"currentModel": "Usado para novas respostas.",
|
||||
"selectedModelProvider": "Definido pelo modelo selecionado.",
|
||||
@@ -332,6 +365,7 @@
|
||||
"compact": "Compacto",
|
||||
"auto": "Automático",
|
||||
"expanded": "Expandido",
|
||||
"default": "Padrão",
|
||||
"summary": "Resumo",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff recolhido",
|
||||
@@ -364,6 +398,10 @@
|
||||
"actions": {
|
||||
"save": "Salvar",
|
||||
"saving": "Salvando",
|
||||
"saveOrder": "Salvar ordem",
|
||||
"savePreset": "Salvar predefinição",
|
||||
"delete": "Excluir",
|
||||
"deleting": "Excluindo...",
|
||||
"edit": "Editar",
|
||||
"cancel": "Cancelar",
|
||||
"open": "Abrir",
|
||||
@@ -372,7 +410,7 @@
|
||||
"exporting": "Exportando..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Traga suas próprias chaves de provedor. O nanobot lê esses valores da configuração atual e apenas provedores configurados podem ser selecionados em Geral.",
|
||||
"description": "Traga suas próprias chaves de provedor. O nanobot lê esses valores da configuração atual e apenas provedores configurados podem ser usados nas predefinições de modelo.",
|
||||
"configured": "Configurado",
|
||||
"notConfigured": "Não configurado",
|
||||
"configuredSection": "Configurados",
|
||||
@@ -449,9 +487,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Provedores de modelos",
|
||||
"searchPlaceholder": "Buscar provedores",
|
||||
"noMatches": "Nenhum provedor corresponde a esta busca.",
|
||||
"saveProvider": "Salvar provedor"
|
||||
"addOwnProvider": "Adicionar seu próprio provedor de modelos",
|
||||
"customProvider": "Provedor personalizado",
|
||||
"customProviderName": "Nome do provedor",
|
||||
"customProviderNamePlaceholder": "Meu provedor de modelos",
|
||||
"saveProvider": "Salvar provedor",
|
||||
"advancedOptions": "Opções avançadas",
|
||||
"saveAdvancedBeforeSignIn": "Salve as alterações avançadas antes de entrar.",
|
||||
"apiType": "Tipo de API",
|
||||
"proxy": "Proxy de rede",
|
||||
"thinkingStyle": "Estilo de raciocínio",
|
||||
"region": "Região",
|
||||
"profile": "Perfil",
|
||||
"extraHeaders": "Cabeçalhos adicionais",
|
||||
"extraBody": "Corpo adicional",
|
||||
"extraQuery": "Parâmetros de consulta adicionais"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso."
|
||||
@@ -721,7 +774,7 @@
|
||||
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
|
||||
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
|
||||
"signInRequired": "Login necessário",
|
||||
"signInBeforeSaving": "Entre antes de salvar este provedor OAuth como provedor de modelo ativo.",
|
||||
"signInBeforeSaving": "Entre neste provedor antes de salvar a predefinição.",
|
||||
"signedIn": "Conectado",
|
||||
"notSignedIn": "Desconectado",
|
||||
"proxyLabel": "Proxy de rede",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "Chuyển giữa giao diện sáng và tối.",
|
||||
"language": "Chọn ngôn ngữ dùng trong WebUI.",
|
||||
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
|
||||
"model": "Define el nombre de modelo predeterminado de nanobot.",
|
||||
"model": "Chọn mô hình mà cấu hình sẵn này sử dụng.",
|
||||
"configPath": "Archivo de configuración que usa actualmente el gateway.",
|
||||
"selectedPreset": "Los preajustes con nombre son de solo lectura aquí; edítalos en config.json.",
|
||||
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "Gọn",
|
||||
"auto": "Tự động",
|
||||
"expanded": "Mở rộng",
|
||||
"default": "Mặc định",
|
||||
"summary": "Tóm tắt",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff thu gọn",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu",
|
||||
"saveOrder": "Lưu thứ tự",
|
||||
"savePreset": "Lưu cấu hình đặt trước",
|
||||
"delete": "Xóa",
|
||||
"deleting": "Đang xóa...",
|
||||
"edit": "Sửa",
|
||||
"cancel": "Hủy",
|
||||
"open": "Mở",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "Đang xuất..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
|
||||
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại và chỉ provider đã cấu hình mới có thể dùng trong cấu hình mô hình đặt trước.",
|
||||
"configured": "Đã cấu hình",
|
||||
"notConfigured": "Chưa cấu hình",
|
||||
"configuredSection": "Đã cấu hình",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Nhà cung cấp mô hình",
|
||||
"searchPlaceholder": "Tìm nhà cung cấp",
|
||||
"noMatches": "Không có nhà cung cấp phù hợp.",
|
||||
"saveProvider": "Lưu nhà cung cấp"
|
||||
"addOwnProvider": "Thêm nhà cung cấp mô hình của bạn",
|
||||
"customProvider": "Nhà cung cấp tùy chỉnh",
|
||||
"customProviderName": "Tên nhà cung cấp",
|
||||
"customProviderNamePlaceholder": "Nhà cung cấp mô hình của tôi",
|
||||
"saveProvider": "Lưu nhà cung cấp",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"saveAdvancedBeforeSignIn": "Lưu các thay đổi nâng cao trước khi đăng nhập.",
|
||||
"apiType": "Loại API",
|
||||
"proxy": "Proxy mạng",
|
||||
"thinkingStyle": "Kiểu suy luận",
|
||||
"region": "Khu vực",
|
||||
"profile": "Hồ sơ",
|
||||
"extraHeaders": "Header bổ sung",
|
||||
"extraBody": "Body bổ sung",
|
||||
"extraQuery": "Tham số truy vấn bổ sung"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "Chọn nhà cung cấp",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "Tên cấu hình",
|
||||
"configurationNameHelp": "Đổi tên cấu hình mô hình đã lưu này.",
|
||||
"configurationNamePlaceholder": "Viết nhanh",
|
||||
"callOrder": "Thứ tự gọi mô hình",
|
||||
"primary": "Chính",
|
||||
"fallbackNumber": "Dự phòng {{number}}",
|
||||
"addToOrder": "Bật cấu hình đặt trước",
|
||||
"newPreset": "Cấu hình mô hình đặt trước mới",
|
||||
"newPresetHelp": "Lưu mô hình có thể tái sử dụng cùng các thiết lập sinh nội dung.",
|
||||
"presets": "Cấu hình mô hình đặt trước",
|
||||
"editPreset": "Sửa cấu hình đặt trước",
|
||||
"presetName": "Tên cấu hình đặt trước",
|
||||
"presetNameHelp": "Tên ngắn dùng trong phần thiết lập mô hình.",
|
||||
"presetNamePlaceholder": "Viết nhanh",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"advancedSummary": "Ngữ cảnh {{context}} · Tối đa {{max}} token",
|
||||
"maxTokens": "Token đầu ra tối đa",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "Mức suy luận",
|
||||
"convertTitle": "Chuyển đổi thiết lập mô hình hiện tại",
|
||||
"convertHelp": "Chuyển mô hình chính và các mô hình dự phòng hiện có thành cấu hình đặt trước để quản lý thứ tự tại đây.",
|
||||
"converting": "Đang chuyển đổi...",
|
||||
"convertAction": "Chuyển thành cấu hình đặt trước",
|
||||
"dragToReorder": "Kéo để sắp xếp lại",
|
||||
"moveUp": "Di chuyển lên",
|
||||
"moveDown": "Di chuyển xuống",
|
||||
"removeFromOrder": "Tắt cấu hình đặt trước",
|
||||
"inCallOrder": "Trong thứ tự gọi",
|
||||
"disabled": "Đã tắt",
|
||||
"noPresets": "Chưa có cấu hình mô hình đặt trước",
|
||||
"noPresetsHelp": "Tạo cấu hình đặt trước rồi thêm vào thứ tự gọi.",
|
||||
"removeBeforeDelete": "Hãy xóa cấu hình này khỏi thứ tự gọi trước khi xóa.",
|
||||
"providerSetupRequired": "Cần cấu hình nhà cung cấp",
|
||||
"configureProviderBeforeSaving": "Hãy cấu hình nhà cung cấp này trước khi lưu cấu hình đặt trước.",
|
||||
"deletePresetTitle": "Xóa cấu hình mô hình đặt trước?",
|
||||
"deletePresetHelp": "Thao tác này xóa cấu hình “{{name}}”. Thông tin xác thực của nhà cung cấp không bị ảnh hưởng.",
|
||||
"searchModels": "Tìm kiếm hoặc nhập ID mô hình",
|
||||
"useCustomModel": "Dùng",
|
||||
"loadingModels": "Đang tải mô hình...",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
||||
"signInRequired": "Cần đăng nhập",
|
||||
"signInBeforeSaving": "Inicia sesión antes de guardar este proveedor OAuth como proveedor activo.",
|
||||
"signInBeforeSaving": "Hãy đăng nhập nhà cung cấp này trước khi lưu cấu hình đặt trước.",
|
||||
"signedIn": "Đã đăng nhập",
|
||||
"notSignedIn": "Chưa đăng nhập",
|
||||
"proxyLabel": "Proxy mạng",
|
||||
|
||||
@@ -115,6 +115,39 @@
|
||||
"configurationName": "配置名称",
|
||||
"configurationNameHelp": "重命名这个已保存的模型配置。",
|
||||
"configurationNamePlaceholder": "快速写作",
|
||||
"callOrder": "模型调用顺序",
|
||||
"primary": "主模型",
|
||||
"fallbackNumber": "备用模型 {{number}}",
|
||||
"addToOrder": "启用预设",
|
||||
"newPreset": "新建模型预设",
|
||||
"newPresetHelp": "保存可复用的模型及其生成参数。",
|
||||
"presets": "模型预设",
|
||||
"editPreset": "编辑预设",
|
||||
"presetName": "预设名称",
|
||||
"presetNameHelp": "在模型设置中使用的简短名称。",
|
||||
"presetNamePlaceholder": "快速写作",
|
||||
"advancedOptions": "高级选项",
|
||||
"advancedSummary": "上下文 {{context}} · 最大输出 {{max}} tokens",
|
||||
"maxTokens": "最大输出 tokens",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "推理强度",
|
||||
"convertTitle": "转换现有模型设置",
|
||||
"convertHelp": "把现有主模型和备用模型转换成预设,之后即可在这里管理调用顺序。",
|
||||
"converting": "正在转换...",
|
||||
"convertAction": "转换为预设",
|
||||
"dragToReorder": "拖动调整顺序",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"removeFromOrder": "停用预设",
|
||||
"inCallOrder": "已加入调用顺序",
|
||||
"disabled": "未启用",
|
||||
"noPresets": "还没有模型预设",
|
||||
"noPresetsHelp": "先创建预设,再将它加入调用顺序。",
|
||||
"removeBeforeDelete": "删除前请先将此预设从调用顺序中移除。",
|
||||
"providerSetupRequired": "提供商需要配置",
|
||||
"configureProviderBeforeSaving": "请先配置此提供商,再保存预设。",
|
||||
"deletePresetTitle": "删除模型预设?",
|
||||
"deletePresetHelp": "将删除预设“{{name}}”,不会影响提供商凭据。",
|
||||
"searchModels": "搜索或输入模型 ID",
|
||||
"useCustomModel": "使用",
|
||||
"loadingModels": "正在加载模型...",
|
||||
@@ -180,7 +213,7 @@
|
||||
"theme": "在浅色和深色外观之间切换。",
|
||||
"language": "选择 WebUI 使用的语言。",
|
||||
"provider": "选择处理新模型请求的提供商。",
|
||||
"model": "设置 nanobot 默认使用的模型名称。",
|
||||
"model": "选择此预设使用的模型。",
|
||||
"configPath": "当前网关正在使用的配置文件。",
|
||||
"currentModel": "用于新的回复。",
|
||||
"selectedModelProvider": "由选中的模型决定。",
|
||||
@@ -331,6 +364,7 @@
|
||||
"comfortable": "舒适",
|
||||
"compact": "紧凑",
|
||||
"auto": "自动",
|
||||
"default": "默认",
|
||||
"expanded": "展开",
|
||||
"summary": "摘要",
|
||||
"diff": "差异",
|
||||
@@ -364,7 +398,11 @@
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"saving": "正在保存",
|
||||
"saveOrder": "保存顺序",
|
||||
"savePreset": "保存预设",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"deleting": "正在删除...",
|
||||
"cancel": "取消",
|
||||
"open": "打开",
|
||||
"export": "导出",
|
||||
@@ -372,7 +410,7 @@
|
||||
"exporting": "正在导出..."
|
||||
},
|
||||
"byok": {
|
||||
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
|
||||
"description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能用于模型预设。",
|
||||
"configured": "已配置",
|
||||
"notConfigured": "未配置",
|
||||
"configuredSection": "已配置",
|
||||
@@ -449,9 +487,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "模型供应商",
|
||||
"searchPlaceholder": "搜索提供商",
|
||||
"noMatches": "没有匹配的提供商。",
|
||||
"saveProvider": "保存提供商"
|
||||
"addOwnProvider": "添加自己的模型供应商",
|
||||
"customProvider": "自定义供应商",
|
||||
"customProviderName": "供应商名称",
|
||||
"customProviderNamePlaceholder": "例如:公司模型网关",
|
||||
"saveProvider": "保存供应商",
|
||||
"advancedOptions": "高级选项",
|
||||
"saveAdvancedBeforeSignIn": "请先保存高级选项再登录。",
|
||||
"apiType": "API 类型",
|
||||
"proxy": "网络代理",
|
||||
"thinkingStyle": "思考模式",
|
||||
"region": "区域",
|
||||
"profile": "配置档案",
|
||||
"extraHeaders": "额外请求头",
|
||||
"extraBody": "额外请求体",
|
||||
"extraQuery": "额外查询参数"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
|
||||
@@ -721,7 +774,7 @@
|
||||
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
|
||||
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
||||
"signInRequired": "需要登录",
|
||||
"signInBeforeSaving": "将此 OAuth 提供商设为当前模型提供商前,请先登录。",
|
||||
"signInBeforeSaving": "请先登录此提供商,再保存模型预设。",
|
||||
"signedIn": "已登录",
|
||||
"notSignedIn": "未登录",
|
||||
"proxyLabel": "网络代理",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"theme": "在淺色與深色外觀之間切換。",
|
||||
"language": "選擇 WebUI 使用的語言。",
|
||||
"provider": "選擇處理新模型請求的供應商。",
|
||||
"model": "設定 nanobot 預設使用的模型名稱。",
|
||||
"model": "選擇此預設使用的模型。",
|
||||
"configPath": "目前閘道使用中的設定檔。",
|
||||
"selectedPreset": "命名預設在此為唯讀;請在 config.json 中編輯。",
|
||||
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
|
||||
@@ -217,6 +217,7 @@
|
||||
"compact": "緊湊",
|
||||
"auto": "自動",
|
||||
"expanded": "展開",
|
||||
"default": "預設",
|
||||
"summary": "摘要",
|
||||
"diff": "差異",
|
||||
"collapsedDiff": "摺疊差異",
|
||||
@@ -249,6 +250,10 @@
|
||||
"actions": {
|
||||
"save": "儲存",
|
||||
"saving": "正在儲存",
|
||||
"saveOrder": "儲存順序",
|
||||
"savePreset": "儲存預設",
|
||||
"delete": "刪除",
|
||||
"deleting": "正在刪除…",
|
||||
"edit": "編輯",
|
||||
"cancel": "取消",
|
||||
"open": "開啟",
|
||||
@@ -257,7 +262,7 @@
|
||||
"exporting": "正在匯出…"
|
||||
},
|
||||
"byok": {
|
||||
"description": "使用自備的供應商金鑰。Nanobot 會從目前的設定檔讀取這些值,只有已設定的供應商可在 [一般] 設定中選擇。",
|
||||
"description": "使用自備的供應商金鑰。Nanobot 會從目前的設定檔讀取這些值,只有已設定的供應商可用於模型預設。",
|
||||
"configured": "已設定",
|
||||
"notConfigured": "未設定",
|
||||
"configuredSection": "已設定",
|
||||
@@ -334,9 +339,24 @@
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "模型供應商",
|
||||
"searchPlaceholder": "搜尋供應商",
|
||||
"noMatches": "沒有符合的供應商。",
|
||||
"saveProvider": "儲存供應商"
|
||||
"addOwnProvider": "新增自己的模型供應商",
|
||||
"customProvider": "自訂供應商",
|
||||
"customProviderName": "供應商名稱",
|
||||
"customProviderNamePlaceholder": "例如:公司模型閘道",
|
||||
"saveProvider": "儲存供應商",
|
||||
"advancedOptions": "進階選項",
|
||||
"saveAdvancedBeforeSignIn": "請先儲存進階選項再登入。",
|
||||
"apiType": "API 類型",
|
||||
"proxy": "網路代理",
|
||||
"thinkingStyle": "思考模式",
|
||||
"region": "區域",
|
||||
"profile": "設定檔",
|
||||
"extraHeaders": "額外請求標頭",
|
||||
"extraBody": "額外請求本文",
|
||||
"extraQuery": "額外查詢參數"
|
||||
},
|
||||
"image": {
|
||||
"selectProvider": "選擇供應商",
|
||||
@@ -353,6 +373,39 @@
|
||||
"configurationName": "設定名稱",
|
||||
"configurationNameHelp": "重新命名這個已儲存的模型設定。",
|
||||
"configurationNamePlaceholder": "快速寫作",
|
||||
"callOrder": "模型呼叫順序",
|
||||
"primary": "主要模型",
|
||||
"fallbackNumber": "備用模型 {{number}}",
|
||||
"addToOrder": "啟用預設",
|
||||
"newPreset": "新增模型預設",
|
||||
"newPresetHelp": "儲存可重複使用的模型與生成參數。",
|
||||
"presets": "模型預設",
|
||||
"editPreset": "編輯預設",
|
||||
"presetName": "預設名稱",
|
||||
"presetNameHelp": "模型設定中使用的簡短名稱。",
|
||||
"presetNamePlaceholder": "快速寫作",
|
||||
"advancedOptions": "進階選項",
|
||||
"advancedSummary": "上下文 {{context}} · 最大輸出 {{max}} tokens",
|
||||
"maxTokens": "最大輸出 tokens",
|
||||
"temperature": "Temperature",
|
||||
"reasoningEffort": "推理強度",
|
||||
"convertTitle": "轉換現有模型設定",
|
||||
"convertHelp": "將現有主要模型和備用模型轉換為預設,之後即可在這裡管理呼叫順序。",
|
||||
"converting": "正在轉換…",
|
||||
"convertAction": "轉換為預設",
|
||||
"dragToReorder": "拖曳調整順序",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"removeFromOrder": "停用預設",
|
||||
"inCallOrder": "已加入呼叫順序",
|
||||
"disabled": "未啟用",
|
||||
"noPresets": "尚無模型預設",
|
||||
"noPresetsHelp": "先建立預設,再將它加入呼叫順序。",
|
||||
"removeBeforeDelete": "刪除前請先將此預設從呼叫順序中移除。",
|
||||
"providerSetupRequired": "供應商需要設定",
|
||||
"configureProviderBeforeSaving": "請先設定此供應商,再儲存預設。",
|
||||
"deletePresetTitle": "刪除模型預設?",
|
||||
"deletePresetHelp": "將刪除預設「{{name}}」,不會影響供應商憑證。",
|
||||
"searchModels": "搜尋或輸入模型 ID",
|
||||
"useCustomModel": "使用",
|
||||
"loadingModels": "正在載入模型…",
|
||||
@@ -707,7 +760,7 @@
|
||||
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
|
||||
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI,完成登入後貼上頁面顯示的授權碼。",
|
||||
"signInRequired": "需要登入",
|
||||
"signInBeforeSaving": "將此 OAuth 供應商設為目前模型供應商前,請先登入。",
|
||||
"signInBeforeSaving": "請先登入此供應商,再儲存模型預設。",
|
||||
"signedIn": "已登入",
|
||||
"notSignedIn": "未登入",
|
||||
"proxyLabel": "網路代理",
|
||||
|
||||
+82
-9
@@ -15,6 +15,7 @@ import type {
|
||||
ModelConfigurationUpdate,
|
||||
NetworkSafetySettingsUpdate,
|
||||
PairingPayload,
|
||||
ProviderCreationUpdate,
|
||||
ProviderModelsPayload,
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthLoginResult,
|
||||
@@ -54,6 +55,7 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
||||
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -767,6 +769,27 @@ export async function updateSettings(
|
||||
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token);
|
||||
}
|
||||
|
||||
function appendModelGenerationSettings(
|
||||
query: URLSearchParams,
|
||||
configuration: Pick<
|
||||
ModelConfigurationCreate,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
>,
|
||||
): void {
|
||||
if (configuration.maxTokens !== undefined) {
|
||||
query.set("max_tokens", String(configuration.maxTokens));
|
||||
}
|
||||
if (configuration.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
||||
}
|
||||
if (configuration.temperature !== undefined) {
|
||||
query.set("temperature", String(configuration.temperature));
|
||||
}
|
||||
if (configuration.reasoningEffort !== undefined) {
|
||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createModelConfiguration(
|
||||
token: string,
|
||||
configuration: ModelConfigurationCreate,
|
||||
@@ -777,6 +800,7 @@ export async function createModelConfiguration(
|
||||
query.set("label", configuration.label);
|
||||
query.set("provider", configuration.provider);
|
||||
query.set("model", configuration.model);
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/create?${query}`,
|
||||
token,
|
||||
@@ -793,29 +817,78 @@ export async function updateModelConfiguration(
|
||||
if (configuration.label !== undefined) query.set("label", configuration.label);
|
||||
if (configuration.provider !== undefined) query.set("provider", configuration.provider);
|
||||
if (configuration.model !== undefined) query.set("model", configuration.model);
|
||||
if (configuration.contextWindowTokens !== undefined) {
|
||||
query.set("context_window_tokens", String(configuration.contextWindowTokens));
|
||||
}
|
||||
appendModelGenerationSettings(query, configuration);
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/update?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteModelConfiguration(
|
||||
token: string,
|
||||
name: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ name });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/delete?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function migrateModelConfigurations(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-configurations/migrate`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateModelCallOrder(
|
||||
token: string,
|
||||
order: string[],
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams({ order: JSON.stringify(order) });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/model-call-order/update?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateProviderSettings(
|
||||
token: string,
|
||||
update: ProviderSettingsUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", update.provider);
|
||||
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
|
||||
if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
|
||||
if (update.apiType !== undefined) query.set("api_type", update.apiType);
|
||||
if (update.proxy !== undefined) query.set("proxy", update.proxy);
|
||||
const { provider, ...values } = update;
|
||||
const query = new URLSearchParams({ provider });
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/update?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createProviderSettings(
|
||||
token: string,
|
||||
update: ProviderCreationUpdate,
|
||||
base: string = "",
|
||||
): Promise<SettingsPayload> {
|
||||
return request<SettingsPayload>(
|
||||
`${base}/api/settings/provider/create`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -432,9 +432,14 @@ export interface SettingsPayload {
|
||||
reasoning_effort: string | null;
|
||||
reasoning_effort_values?: string[];
|
||||
}>;
|
||||
model_call_order: string[];
|
||||
model_call_order_editable: boolean;
|
||||
created_model_preset?: string;
|
||||
created_provider?: string;
|
||||
providers: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
is_custom?: boolean;
|
||||
configured: boolean;
|
||||
auth_type?: "api_key" | "oauth";
|
||||
api_key_required?: boolean;
|
||||
@@ -448,6 +453,22 @@ export interface SettingsPayload {
|
||||
oauth_expires_at?: number | null;
|
||||
oauth_login_supported?: boolean;
|
||||
proxy?: string | null;
|
||||
advanced_fields?: Array<
|
||||
| "api_type"
|
||||
| "extra_headers"
|
||||
| "extra_body"
|
||||
| "extra_query"
|
||||
| "proxy"
|
||||
| "thinking_style"
|
||||
| "region"
|
||||
| "profile"
|
||||
>;
|
||||
extra_headers?: Record<string, string> | null;
|
||||
extra_body?: Record<string, unknown> | null;
|
||||
extra_query?: Record<string, string> | null;
|
||||
thinking_style?: string | null;
|
||||
region?: string | null;
|
||||
profile?: string | null;
|
||||
}>;
|
||||
web_search: {
|
||||
provider: string;
|
||||
@@ -941,6 +962,10 @@ export interface ModelConfigurationCreate {
|
||||
label: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
maxTokens?: number;
|
||||
contextWindowTokens?: number;
|
||||
temperature?: number;
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export interface ModelConfigurationUpdate {
|
||||
@@ -948,15 +973,36 @@ export interface ModelConfigurationUpdate {
|
||||
label?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
contextWindowTokens?: number;
|
||||
temperature?: number;
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsUpdate {
|
||||
provider: string;
|
||||
displayName?: string;
|
||||
apiKey?: string;
|
||||
apiBase?: string;
|
||||
apiType?: "auto" | "chat_completions" | "responses";
|
||||
proxy?: string;
|
||||
extraHeaders?: string;
|
||||
extraBody?: string;
|
||||
extraQuery?: string;
|
||||
thinkingStyle?: string;
|
||||
region?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
export interface ProviderCreationUpdate {
|
||||
name: string;
|
||||
apiKey?: string;
|
||||
apiBase: string;
|
||||
proxy?: string;
|
||||
extraHeaders?: string;
|
||||
extraBody?: string;
|
||||
extraQuery?: string;
|
||||
thinkingStyle?: string;
|
||||
}
|
||||
|
||||
export interface WebSearchSettingsUpdate {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
configureChannel,
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchFilePreviewAvailability,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
logoutProviderOAuth,
|
||||
migrateModelConfigurations,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
runAutomationAction,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
updateAutomation,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
updateModelCallOrder,
|
||||
updateModelConfiguration,
|
||||
updateMcpServerTools,
|
||||
updateNetworkSafetySettings,
|
||||
@@ -340,10 +344,14 @@ describe("webui API helpers", () => {
|
||||
label: "Fast writing",
|
||||
provider: "openai",
|
||||
model: "openai/gpt-4.1-mini",
|
||||
maxTokens: 4096,
|
||||
contextWindowTokens: 128000,
|
||||
temperature: 0.4,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||
"/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
@@ -356,11 +364,45 @@ describe("webui API helpers", () => {
|
||||
label: "Codex",
|
||||
provider: "openai_codex",
|
||||
model: "openai-codex/gpt-5.5",
|
||||
maxTokens: 8192,
|
||||
contextWindowTokens: 65536,
|
||||
temperature: 0,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536",
|
||||
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model preset deletion and migration", async () => {
|
||||
await deleteModelConfiguration("tok", "spare");
|
||||
await migrateModelConfigurations("tok");
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/settings/model-configurations/delete?name=spare",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/settings/model-configurations/migrate",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes model call order as an ordered JSON array", async () => {
|
||||
await updateModelCallOrder("tok", ["backup", "primary"]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
@@ -425,23 +467,68 @@ describe("webui API helpers", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=openrouter&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||
"/api/settings/provider/update?provider=openrouter",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
apiKey: "sk-or-test",
|
||||
apiBase: "https://openrouter.ai/api/v1",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes OAuth provider proxy updates", async () => {
|
||||
it("serializes OAuth provider advanced settings", async () => {
|
||||
await updateProviderSettings("tok", {
|
||||
provider: "xai_grok",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/update?provider=xai_grok&proxy=http%3A%2F%2F127.0.0.1%3A7890",
|
||||
"/api/settings/provider/update?provider=xai_grok",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes custom provider creation with advanced settings", async () => {
|
||||
await createProviderSettings("tok", {
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
extraHeaders: '{"X-Tenant":"engineering"}',
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/create",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({
|
||||
name: "Company Gateway",
|
||||
apiKey: "sk-company",
|
||||
apiBase: "https://gateway.example/v1",
|
||||
extraHeaders: '{"X-Tenant":"engineering"}',
|
||||
extraBody: '{"service_tier":"priority"}',
|
||||
extraQuery: '{"api-version":"2026-01-01"}',
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
thinkingStyle: "enable_thinking",
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ function baseSettingsPayload() {
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
model_call_order: [],
|
||||
model_call_order_editable: false,
|
||||
providers: [],
|
||||
web_search: {
|
||||
provider: "duckduckgo",
|
||||
@@ -370,7 +372,10 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Channels" })).not.toBeInTheDocument();
|
||||
expect(window.location.hash).toBe("#/settings?section=channels");
|
||||
});
|
||||
|
||||
@@ -1403,7 +1408,7 @@ describe("App layout", () => {
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
has_api_key: true,
|
||||
model_preset: "default",
|
||||
model_preset: "primary",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
@@ -1415,12 +1420,13 @@ describe("App layout", () => {
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
name: "default",
|
||||
label: "Default",
|
||||
name: "primary",
|
||||
label: "Primary",
|
||||
active: true,
|
||||
is_default: true,
|
||||
is_default: false,
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
@@ -1439,6 +1445,8 @@ describe("App layout", () => {
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
],
|
||||
model_call_order: ["primary", "deep"],
|
||||
model_call_order_editable: true,
|
||||
providers: [
|
||||
{
|
||||
name: "openai",
|
||||
@@ -1599,7 +1607,10 @@ describe("App layout", () => {
|
||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Overview" })).not.toBeInTheDocument();
|
||||
expect(document.title).toBe("Settings · nanobot");
|
||||
expect(screen.getByTestId("overview-logo-openai")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("overview-logo-brave")).toBeInTheDocument();
|
||||
@@ -1630,21 +1641,26 @@ describe("App layout", () => {
|
||||
fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Models" }));
|
||||
expect(screen.queryByText("AI")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Current configuration")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Current configuration" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Add configuration" }));
|
||||
const modelDialog = await screen.findByRole("dialog", { name: "New model configuration" });
|
||||
expect(within(modelDialog).getByText("Save a provider and model as a one-click option.")).toBeInTheDocument();
|
||||
fireEvent.change(within(modelDialog).getByPlaceholderText("Fast writing"), {
|
||||
expect(screen.getByText("Model presets")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Model call order")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
|
||||
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
|
||||
target: { value: "Fast writing" },
|
||||
});
|
||||
fireEvent.change(within(modelDialog).getByPlaceholderText("openai/gpt-4.1"), {
|
||||
target: { value: "openai/gpt-4.1-mini" },
|
||||
});
|
||||
expect(within(modelDialog).getByRole("button", { name: /OpenAI/ })).toBeInTheDocument();
|
||||
expect(within(modelDialog).getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
fireEvent.click(within(modelDialog).getByRole("button", { name: "Cancel" }));
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("button", { name: /OpenAI/ })
|
||||
.some((button) => button.getAttribute("aria-haspopup") === "menu"),
|
||||
).toBe(true);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
|
||||
fireEvent.click(await screen.findByText("openai/gpt-4o-mini"));
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
within(screen.getByTestId("model-call-order-row-primary")).getAllByRole("button")[0],
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||
@@ -1655,18 +1671,20 @@ describe("App layout", () => {
|
||||
openModelPicker();
|
||||
await screen.findByText("openai/gpt-4o-mini");
|
||||
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
|
||||
expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
|
||||
"text-blue-600",
|
||||
);
|
||||
const updatedModelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o-mini/ });
|
||||
fireEvent.pointerDown(updatedModelButtons[updatedModelButtons.length - 1]);
|
||||
await screen.findByText("openai/gpt-4o");
|
||||
fireEvent.click(screen.getAllByText("openai/gpt-4o")[0]);
|
||||
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Model providers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("OpenRouter")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Ant Ling")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be used in model presets.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("azure_openai")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/Product names, logos, and brands/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Not configured")).not.toBeInTheDocument();
|
||||
const clickProviderRow = (label: string) => {
|
||||
const providerLabel = screen
|
||||
.getAllByText(label)
|
||||
@@ -1674,23 +1692,33 @@ describe("App layout", () => {
|
||||
expect(providerLabel).toBeTruthy();
|
||||
fireEvent.click(providerLabel!);
|
||||
};
|
||||
const chooseProvider = async (label: string) => {
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("button", { name: "Add your own model provider" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: label }));
|
||||
};
|
||||
clickProviderRow("OpenAI");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Leave blank to keep the current key"), {
|
||||
target: { value: "unsaved-openai-key" },
|
||||
});
|
||||
clickProviderRow("OpenAI");
|
||||
await chooseProvider("OpenRouter");
|
||||
clickProviderRow("OpenRouter");
|
||||
clickProviderRow("OpenAI");
|
||||
expect(screen.getByText("open••••-key")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
|
||||
clickProviderRow("Ant Ling");
|
||||
clickProviderRow("OpenAI");
|
||||
await chooseProvider("Ant Ling");
|
||||
expect(screen.getByDisplayValue("https://api.ant-ling.com/v1")).toBeInTheDocument();
|
||||
clickProviderRow("Atomic Chat");
|
||||
clickProviderRow("Ant Ling");
|
||||
await chooseProvider("Atomic Chat");
|
||||
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save provider" })).toBeEnabled();
|
||||
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
|
||||
expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Image" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Image generation" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Provider status")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "openai/gpt-5.4-image-2" })).toBeInTheDocument();
|
||||
@@ -1750,7 +1778,10 @@ describe("App layout", () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Overview" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the URL hash when switching settings sections", async () => {
|
||||
@@ -1761,13 +1792,16 @@ describe("App layout", () => {
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(window.location.hash).toBe("#/settings");
|
||||
|
||||
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Models" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("Model presets")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
|
||||
expect(window.location.hash).toBe("#/settings?section=models");
|
||||
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" }));
|
||||
@@ -1939,7 +1973,9 @@ describe("App layout", () => {
|
||||
await waitFor(() => expect(document.title).toBe("nanobot"));
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
|
||||
|
||||
await waitFor(() => expect(document.title).toBe("nanobot"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -170,6 +170,8 @@ function modelSettings(model: string, provider: string): SettingsPayload {
|
||||
temperature: 0.7,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
model_call_order: [],
|
||||
model_call_order_editable: false,
|
||||
providers: [
|
||||
{ name: "deepseek", label: "DeepSeek", configured: true },
|
||||
{ name: "openai_codex", label: "OpenAI Codex", configured: true },
|
||||
|
||||
Reference in New Issue
Block a user