mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(models): preserve preset rename compatibility
This commit is contained in:
@@ -1435,7 +1435,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
|
||||
}
|
||||
```
|
||||
|
||||
`modelPresets` is a top-level object. Each key (`fast`, `deep`, `coding`, etc.) is the preset's one canonical name: it is shown in the interface, passed to `/model <name>`, and referenced by defaults, fallbacks, sessions, and Dream. Names must be unique ignoring case. Each preset supports:
|
||||
`modelPresets` is a top-level object. Each key (`fast`, `deep`, `coding`, etc.) is the preset's one canonical name: it is shown in the interface, passed to `/model <name>`, and referenced by defaults, fallbacks, sessions, and Dream. New and renamed presets must be unique ignoring case. Existing keys accepted by earlier releases remain loadable so upgrades do not break startup. Each preset supports:
|
||||
|
||||
Older configs may still contain a `label` inside a preset. It is accepted when loading for compatibility but ignored; the object key remains the canonical name.
|
||||
|
||||
|
||||
@@ -32,16 +32,14 @@ with ones you control:
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"fast": {
|
||||
"label": "Fast",
|
||||
"Fast": {
|
||||
"provider": "primary-provider",
|
||||
"model": "primary-model-id",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 65536,
|
||||
"temperature": 0.1
|
||||
},
|
||||
"deep": {
|
||||
"label": "Deep",
|
||||
"Deep": {
|
||||
"provider": "fallback-provider",
|
||||
"model": "fallback-model-id",
|
||||
"maxTokens": 4096,
|
||||
@@ -51,8 +49,8 @@ with ones you control:
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "fast",
|
||||
"fallbackModels": ["deep"]
|
||||
"modelPreset": "Fast",
|
||||
"fallbackModels": ["Deep"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +67,7 @@ for common providers.
|
||||
how much context can fit.
|
||||
- Put cheaper or faster fallbacks before expensive ones when acceptable.
|
||||
- Use `/model <preset>` for runtime switching without editing config.
|
||||
- Keep labels human-readable for WebUI model lists.
|
||||
- Keep preset names human-readable; the same name appears in the WebUI and `/model`.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -179,8 +179,7 @@ Merge this preset into `~/.nanobot/config.json` and select it:
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"ollamaPrefixStable": {
|
||||
"label": "Ollama Llama 3.1 prefix-stable",
|
||||
"Ollama Llama 3.1 prefix-stable": {
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1:8b-prefix-stable-v1",
|
||||
"maxTokens": 2048,
|
||||
@@ -190,7 +189,7 @@ Merge this preset into `~/.nanobot/config.json` and select it:
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "ollamaPrefixStable"
|
||||
"modelPreset": "Ollama Llama 3.1 prefix-stable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,7 @@ Merge this into `~/.nanobot/config.json`:
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"primary": {
|
||||
"label": "Custom",
|
||||
"Custom": {
|
||||
"provider": "custom",
|
||||
"model": "provider-model-name",
|
||||
"maxTokens": 4096,
|
||||
@@ -52,7 +51,7 @@ Merge this into `~/.nanobot/config.json`:
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "primary"
|
||||
"modelPreset": "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ class ChannelManager:
|
||||
cron_service: CronService | None = None,
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||
webui_refresh_runtime_config: Callable[[], None] | None = None,
|
||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
webui_static_dist: bool = True,
|
||||
@@ -116,6 +117,7 @@ class ChannelManager:
|
||||
self._cron_service = cron_service
|
||||
self._local_trigger_store = local_trigger_store
|
||||
self._webui_runtime_model_name = webui_runtime_model_name
|
||||
self._webui_refresh_runtime_config = webui_refresh_runtime_config
|
||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
||||
self._webui_static_dist = webui_static_dist
|
||||
@@ -183,6 +185,7 @@ class ChannelManager:
|
||||
config_path=self._config_path,
|
||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||
runtime_model_name=self._webui_runtime_model_name,
|
||||
refresh_runtime_config=self._webui_refresh_runtime_config,
|
||||
runtime_surface=self._webui_runtime_surface,
|
||||
runtime_capabilities_overrides=self._webui_runtime_capabilities,
|
||||
cron_service=self._cron_service,
|
||||
|
||||
@@ -657,6 +657,9 @@ def _run_gateway(
|
||||
def _webui_runtime_model_name() -> str | None:
|
||||
return agent.model.strip() or None
|
||||
|
||||
def _webui_refresh_runtime_config() -> None:
|
||||
agent.invalidate_runtime_config()
|
||||
|
||||
def _webui_skill_state_action(disabled_skills: set[str]) -> None:
|
||||
config.agents.defaults.disabled_skills = sorted(disabled_skills)
|
||||
agent.context.skills.disabled_skills = set(disabled_skills)
|
||||
@@ -671,6 +674,7 @@ def _run_gateway(
|
||||
cron_service=cron,
|
||||
local_trigger_store=trigger_store,
|
||||
webui_runtime_model_name=_webui_runtime_model_name,
|
||||
webui_refresh_runtime_config=_webui_refresh_runtime_config,
|
||||
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||
webui_static_dist=webui_static_dist,
|
||||
|
||||
@@ -462,18 +462,10 @@ class Config(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_model_preset(self) -> "Config":
|
||||
names_by_case: dict[str, str] = {}
|
||||
for preset_name in self.model_presets:
|
||||
if preset_name != preset_name.strip() or not preset_name.isprintable():
|
||||
raise ValueError(f"invalid model_preset name {preset_name!r}")
|
||||
normalized = preset_name.casefold()
|
||||
if normalized in names_by_case:
|
||||
raise ValueError(
|
||||
"model_preset names must be unique ignoring case: "
|
||||
f"{names_by_case[normalized]!r} and {preset_name!r}"
|
||||
)
|
||||
names_by_case[normalized] = preset_name
|
||||
if "default" in names_by_case:
|
||||
# Keep persisted names accepted by previous releases loadable. New
|
||||
# names are normalized and checked case-insensitively at mutation
|
||||
# boundaries, where conflicts can be reported without breaking startup.
|
||||
if "default" in self.model_presets:
|
||||
raise ValueError("model_preset name 'default' is reserved for agents.defaults")
|
||||
name = self.agents.defaults.model_preset
|
||||
if name and name != "default" and name not in self.model_presets:
|
||||
|
||||
@@ -26,6 +26,7 @@ from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
public_history_message,
|
||||
)
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.utils.helpers import (
|
||||
content_with_media_breadcrumbs,
|
||||
ensure_dir,
|
||||
@@ -1663,6 +1664,47 @@ class SessionManager:
|
||||
self._store.save(session, fsync=fsync)
|
||||
self._remember(session)
|
||||
|
||||
def rename_model_preset(self, old_name: str, new_name: str) -> int:
|
||||
"""Rename a session-scoped model preset across durable and live sessions."""
|
||||
if old_name == new_name:
|
||||
return 0
|
||||
|
||||
cached = dict(self._overflow_cache.items())
|
||||
cached.update(self._cache)
|
||||
keys = set(cached)
|
||||
keys.update(item["key"] for item in self._store.list_sessions())
|
||||
|
||||
changed: list[Session] = []
|
||||
try:
|
||||
for key in sorted(keys):
|
||||
session = cached.get(key) or self._load(key)
|
||||
if (
|
||||
session is None
|
||||
or session.metadata.get(SESSION_MODEL_PRESET_METADATA_KEY) != old_name
|
||||
):
|
||||
continue
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = new_name
|
||||
changed.append(session)
|
||||
if session.policy.persist:
|
||||
self.save(session, fsync=True)
|
||||
else:
|
||||
self._remember(session)
|
||||
except BaseException:
|
||||
for session in reversed(changed):
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = old_name
|
||||
try:
|
||||
if session.policy.persist:
|
||||
self.save(session, fsync=True)
|
||||
else:
|
||||
self._remember(session)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to roll back model preset rename for session {}",
|
||||
session.key,
|
||||
)
|
||||
raise
|
||||
return len(changed)
|
||||
|
||||
def flush_all(self) -> int:
|
||||
"""Re-save every cached session with fsync for durable shutdown.
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ def build_gateway_services(
|
||||
default_restrict_to_workspace: bool,
|
||||
config_path: Path | None = None,
|
||||
runtime_model_name: Callable[[], str | None] | None,
|
||||
refresh_runtime_config: Callable[[], None] | None = None,
|
||||
runtime_surface: str,
|
||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||
disabled_skills: set[str] | None = None,
|
||||
@@ -70,7 +71,15 @@ def build_gateway_services(
|
||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||
logger: Any = default_logger,
|
||||
) -> GatewayServices:
|
||||
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||
settings = WebUISettingsServices.create(
|
||||
config_path or get_config_path(),
|
||||
rename_model_preset=(
|
||||
session_manager.rename_model_preset
|
||||
if session_manager is not None
|
||||
else None
|
||||
),
|
||||
refresh_runtime_config=refresh_runtime_config,
|
||||
)
|
||||
tokens = GatewayTokenStore()
|
||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||
|
||||
@@ -7,7 +7,7 @@ domains; this module preserves the established Python and HTTP-facing seams.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
@@ -234,14 +234,32 @@ def update_model_configuration(
|
||||
query: QueryParams,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
rename_model_preset: Callable[[str, str], int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
config = _load_settings_config(config_path)
|
||||
if models.update_model_configuration(
|
||||
names_before = set(config.model_presets)
|
||||
changed = models.update_model_configuration(
|
||||
config,
|
||||
query,
|
||||
oauth_status=_oauth_provider_status,
|
||||
):
|
||||
_save_settings_config(config, config_path)
|
||||
)
|
||||
if changed:
|
||||
removed = names_before - set(config.model_presets)
|
||||
added = set(config.model_presets) - names_before
|
||||
rename = (
|
||||
(next(iter(removed)), next(iter(added)))
|
||||
if len(removed) == len(added) == 1
|
||||
else None
|
||||
)
|
||||
if rename is not None and rename_model_preset is not None:
|
||||
rename_model_preset(*rename)
|
||||
try:
|
||||
_save_settings_config(config, config_path)
|
||||
except BaseException:
|
||||
rename_model_preset(rename[1], rename[0])
|
||||
raise
|
||||
else:
|
||||
_save_settings_config(config, config_path)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
|
||||
@@ -1661,9 +1661,18 @@ class ModelSettingsHandler:
|
||||
restart_section="runtime",
|
||||
)
|
||||
|
||||
if action == "model-update":
|
||||
payload = self.settings.mutate(
|
||||
operations.update_model,
|
||||
request.query,
|
||||
rename_model_preset=self.settings.rename_model_preset,
|
||||
)
|
||||
if self.settings.refresh_runtime_config is not None:
|
||||
self.settings.refresh_runtime_config()
|
||||
return SettingsRouteResult.success(payload, decorate_restart=True)
|
||||
|
||||
mutation = {
|
||||
"model-create": operations.create_model,
|
||||
"model-update": operations.update_model,
|
||||
"model-delete": operations.delete_model,
|
||||
"models-migrate": operations.migrate_models,
|
||||
"call-order-update": operations.update_call_order,
|
||||
|
||||
@@ -113,12 +113,22 @@ class WebUISettingsServices:
|
||||
|
||||
config: WebUISettingsConfig
|
||||
oauth_flows: WebUIOAuthFlowRegistry
|
||||
rename_model_preset: Callable[[str, str], int] | None = None
|
||||
refresh_runtime_config: Callable[[], None] | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, config_path: Path) -> WebUISettingsServices:
|
||||
def create(
|
||||
cls,
|
||||
config_path: Path,
|
||||
*,
|
||||
rename_model_preset: Callable[[str, str], int] | None = None,
|
||||
refresh_runtime_config: Callable[[], None] | None = None,
|
||||
) -> WebUISettingsServices:
|
||||
return cls(
|
||||
config=WebUISettingsConfig(config_path),
|
||||
oauth_flows=WebUIOAuthFlowRegistry(),
|
||||
rename_model_preset=rename_model_preset,
|
||||
refresh_runtime_config=refresh_runtime_config,
|
||||
)
|
||||
|
||||
def read(
|
||||
|
||||
@@ -289,14 +289,23 @@ def test_legacy_model_preset_label_is_ignored() -> None:
|
||||
assert "label" not in config.model_presets["gpt-5-6-sol"].model_dump()
|
||||
|
||||
|
||||
def test_model_preset_names_are_unique_ignoring_case() -> None:
|
||||
with pytest.raises(ValueError, match="unique ignoring case"):
|
||||
Config.model_validate({
|
||||
"modelPresets": {
|
||||
"Fast": {"model": "openai/gpt-4.1-mini"},
|
||||
"fast": {"model": "openai/gpt-4.1"},
|
||||
}
|
||||
})
|
||||
@pytest.mark.parametrize(
|
||||
"model_presets",
|
||||
[
|
||||
{"Default": {"model": "openai/gpt-4.1"}},
|
||||
{
|
||||
"Fast": {"model": "openai/gpt-4.1-mini"},
|
||||
"fast": {"model": "openai/gpt-4.1"},
|
||||
},
|
||||
{" fast ": {"model": "openai/gpt-4.1"}},
|
||||
],
|
||||
)
|
||||
def test_model_preset_names_accepted_by_earlier_releases_remain_loadable(
|
||||
model_presets: dict[str, dict[str, str]],
|
||||
) -> None:
|
||||
config = Config.model_validate({"modelPresets": model_presets})
|
||||
|
||||
assert list(config.model_presets) == list(model_presets)
|
||||
|
||||
|
||||
def test_model_presets_serializes_with_camel_case_root_key() -> None:
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
import nanobot.session as session_api
|
||||
from nanobot.session import Session, SessionManager
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
|
||||
|
||||
def test_store_types_are_not_public_session_api() -> None:
|
||||
@@ -61,6 +62,38 @@ def test_manager_delegates_persistence_to_store(tmp_path) -> None:
|
||||
assert manager.get_cached(stored.key) is None
|
||||
|
||||
|
||||
def test_manager_renames_model_preset_in_live_and_persisted_sessions(tmp_path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
sessions_root = tmp_path / "sessions"
|
||||
manager = SessionManager(workspace, sessions_root=sessions_root)
|
||||
selected = manager.get_or_create("websocket:selected")
|
||||
selected.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "openai"
|
||||
manager.save(selected)
|
||||
other = manager.get_or_create("websocket:other")
|
||||
other.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "backup"
|
||||
manager.save(other)
|
||||
transient = manager.get_or_create_transient("websocket:temporary")
|
||||
transient.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "openai"
|
||||
|
||||
assert manager.rename_model_preset("openai", "Codex") == 2
|
||||
assert selected.metadata[SESSION_MODEL_PRESET_METADATA_KEY] == "Codex"
|
||||
assert transient.metadata[SESSION_MODEL_PRESET_METADATA_KEY] == "Codex"
|
||||
|
||||
reloaded = SessionManager(workspace, sessions_root=sessions_root)
|
||||
assert (
|
||||
reloaded.get_or_create("websocket:selected").metadata[
|
||||
SESSION_MODEL_PRESET_METADATA_KEY
|
||||
]
|
||||
== "Codex"
|
||||
)
|
||||
assert (
|
||||
reloaded.get_or_create("websocket:other").metadata[
|
||||
SESSION_MODEL_PRESET_METADATA_KEY
|
||||
]
|
||||
== "backup"
|
||||
)
|
||||
|
||||
|
||||
def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
|
||||
store = MagicMock(spec=SessionStore)
|
||||
archiver = MagicMock()
|
||||
|
||||
@@ -52,6 +52,20 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
||||
assert rows[0]["model_preset"] == "fast"
|
||||
|
||||
|
||||
def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:renamed-preset")
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "openai"
|
||||
session.add_message("user", "hello")
|
||||
manager.save(session)
|
||||
|
||||
assert list_webui_sessions(manager)[0]["model_preset"] == "openai"
|
||||
|
||||
assert manager.rename_model_preset("openai", "Codex") == 1
|
||||
|
||||
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
||||
|
||||
|
||||
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:unique-index-temp")
|
||||
|
||||
@@ -10,6 +10,8 @@ import pytest
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_docs_version,
|
||||
@@ -434,9 +436,17 @@ def test_update_model_configuration_renames_preset_and_config_references(
|
||||
defaults.dream.model_override = "openai"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
session_manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "sessions",
|
||||
)
|
||||
session = session_manager.get_or_create("websocket:selected")
|
||||
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "openai"
|
||||
session_manager.save(session)
|
||||
|
||||
payload = update_model_configuration(
|
||||
{"name": ["openai"], "new_name": ["Codex"]}
|
||||
{"name": ["openai"], "new_name": ["Codex"]},
|
||||
rename_model_preset=session_manager.rename_model_preset,
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "Codex"
|
||||
@@ -451,6 +461,11 @@ def test_update_model_configuration_renames_preset_and_config_references(
|
||||
assert saved.agents.defaults.model_preset == "Codex"
|
||||
assert saved.agents.defaults.fallback_models == ["backup", "Codex"]
|
||||
assert saved.agents.defaults.dream.model_override == "Codex"
|
||||
persisted = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "sessions",
|
||||
).get_or_create("websocket:selected")
|
||||
assert persisted.metadata[SESSION_MODEL_PRESET_METADATA_KEY] == "Codex"
|
||||
|
||||
|
||||
def test_update_model_configuration_rejects_duplicate_rename(
|
||||
@@ -473,6 +488,33 @@ def test_update_model_configuration_rejects_duplicate_rename(
|
||||
assert set(load_config(config_path).model_presets) == {"openai", "Codex"}
|
||||
|
||||
|
||||
def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config(
|
||||
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
|
||||
)
|
||||
save_config(config, config_path)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def fail_save(_config: Config, _path) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_api._save_settings_config", fail_save)
|
||||
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
update_model_configuration(
|
||||
{"name": ["openai"], "new_name": ["Codex"]},
|
||||
config_path=config_path,
|
||||
rename_model_preset=lambda old, new: calls.append((old, new)) or 1,
|
||||
)
|
||||
|
||||
assert calls == [("openai", "Codex"), ("Codex", "openai")]
|
||||
assert list(load_config(config_path).model_presets) == ["openai"]
|
||||
|
||||
|
||||
def test_settings_payload_exposes_named_model_call_order(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -24,9 +24,15 @@ def _router(
|
||||
config_path: Path | None = None,
|
||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||
mcp_reload: Callable[[], Awaitable[dict[str, object]]] | None = None,
|
||||
rename_model_preset: Callable[[str, str], int] | None = None,
|
||||
refresh_runtime_config: Callable[[], None] | None = None,
|
||||
) -> WebUISettingsRouter:
|
||||
return WebUISettingsRouter(
|
||||
settings=WebUISettingsServices.create(config_path or get_config_path()),
|
||||
settings=WebUISettingsServices.create(
|
||||
config_path or get_config_path(),
|
||||
rename_model_preset=rename_model_preset,
|
||||
refresh_runtime_config=refresh_runtime_config,
|
||||
),
|
||||
bus=SimpleNamespace(),
|
||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||
check_api_token=lambda _request: authorized,
|
||||
@@ -346,6 +352,38 @@ async def test_model_preset_mutation_routes(
|
||||
assert captured["query"] == expected_query
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_update_route_forwards_session_rename_dependency(monkeypatch) -> None:
|
||||
rename_model_preset = MagicMock(return_value=2)
|
||||
refresh_runtime_config = MagicMock()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def update(query, *, config_path=None, rename_model_preset=None):
|
||||
captured.update(query=query, rename_model_preset=rename_model_preset)
|
||||
return {"updated": True}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.update_model_configuration", update)
|
||||
path = "/api/settings/model-configurations/update"
|
||||
request = _mutation_request(path, {"name": "openai", "new_name": "Codex"})
|
||||
|
||||
response = await _router(
|
||||
rename_model_preset=rename_model_preset,
|
||||
refresh_runtime_config=refresh_runtime_config,
|
||||
).dispatch(
|
||||
None,
|
||||
request,
|
||||
path,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
assert captured == {
|
||||
"query": {"name": ["openai"], "new_name": ["Codex"]},
|
||||
"rename_model_preset": rename_model_preset,
|
||||
}
|
||||
refresh_runtime_config.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
|
||||
path = "/api/settings/provider/update"
|
||||
|
||||
@@ -225,7 +225,8 @@ export function useModelSettingsActions({
|
||||
);
|
||||
if (!selectedPreset) return;
|
||||
const nextName = form.modelPreset.trim();
|
||||
if (presetNameConflict(nextName, selectedPreset.name)) {
|
||||
const nameChanged = form.modelPreset !== selectedPreset.name;
|
||||
if (nameChanged && presetNameConflict(nextName, selectedPreset.name)) {
|
||||
showPresetNameConflict();
|
||||
return;
|
||||
}
|
||||
@@ -235,7 +236,7 @@ export function useModelSettingsActions({
|
||||
try {
|
||||
const payload = await updateModelConfiguration(client, {
|
||||
name: selectedPreset.name,
|
||||
newName: nextName !== selectedPreset.name ? nextName : undefined,
|
||||
newName: nameChanged ? nextName : undefined,
|
||||
model: form.model !== selectedPreset.model ? form.model : undefined,
|
||||
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
|
||||
maxTokens:
|
||||
|
||||
@@ -260,7 +260,7 @@ export function useSettingsController({
|
||||
);
|
||||
if (!selectedPreset) return false;
|
||||
return (
|
||||
form.modelPreset.trim() !== selectedPreset.name ||
|
||||
form.modelPreset !== selectedPreset.name ||
|
||||
form.model !== selectedPreset.model ||
|
||||
form.provider !== selectedPreset.provider ||
|
||||
form.maxTokens !== selectedPreset.max_tokens ||
|
||||
|
||||
@@ -112,6 +112,47 @@ describe("Settings models", () => {
|
||||
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits a legacy case-conflicting preset without treating its own name as a rename", async () => {
|
||||
const payload = settingsPayload();
|
||||
const primary = {
|
||||
...payload.model_presets[0],
|
||||
name: "Fast",
|
||||
label: "Fast",
|
||||
active: true,
|
||||
};
|
||||
const legacyConflict = {
|
||||
...primary,
|
||||
name: "fast",
|
||||
label: "fast",
|
||||
active: false,
|
||||
};
|
||||
const legacyPayload: SettingsPayload = {
|
||||
...payload,
|
||||
agent: { ...payload.agent, model_preset: "Fast" },
|
||||
model_presets: [primary, legacyConflict],
|
||||
model_call_order: ["Fast", "fast"],
|
||||
};
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
requestMutationMock.mockResolvedValueOnce(legacyPayload);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: legacyPayload });
|
||||
await togglePresetEditor("Fast");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
|
||||
fireEvent.change(screen.getByLabelText("Temperature"), {
|
||||
target: { value: "0.4" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.model_configuration.update",
|
||||
{ name: "Fast", temperature: 0.4 },
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renames an existing preset without losing the editor selection", async () => {
|
||||
const payload = settingsPayload();
|
||||
const renamedPayload: SettingsPayload = {
|
||||
|
||||
Reference in New Issue
Block a user