From 0a6ee1c539b119e5b69306e9bd47f02ba4746b68 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:49:18 +0800 Subject: [PATCH] fix(models): preserve preset rename compatibility --- docs/configuration.md | 2 +- docs/guides/configure-model-fallback.md | 12 +++-- docs/guides/configure-ollama-prompt-cache.md | 5 +-- .../configure-openai-compatible-provider.md | 5 +-- nanobot/channels/manager.py | 3 ++ nanobot/cli/gateway_runtime.py | 4 ++ nanobot/config/schema.py | 16 ++----- nanobot/session/manager.py | 42 ++++++++++++++++++ nanobot/webui/gateway_services.py | 11 ++++- nanobot/webui/settings_api.py | 26 +++++++++-- nanobot/webui/settings_models.py | 11 ++++- nanobot/webui/settings_services.py | 12 ++++- tests/config/test_model_presets.py | 25 +++++++---- tests/session/test_session_store.py | 33 ++++++++++++++ tests/webui/test_session_list_index.py | 14 ++++++ tests/webui/test_settings_api.py | 44 ++++++++++++++++++- tests/webui/test_settings_routes.py | 40 ++++++++++++++++- .../models/useModelSettingsActions.ts | 5 ++- .../settings/useSettingsController.ts | 2 +- webui/src/tests/settings-models.test.tsx | 41 +++++++++++++++++ 20 files changed, 307 insertions(+), 46 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9ab884fd9..790363da2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `, 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 `, 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. diff --git a/docs/guides/configure-model-fallback.md b/docs/guides/configure-model-fallback.md index c075b66f0..93a428df9 100644 --- a/docs/guides/configure-model-fallback.md +++ b/docs/guides/configure-model-fallback.md @@ -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 ` 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 diff --git a/docs/guides/configure-ollama-prompt-cache.md b/docs/guides/configure-ollama-prompt-cache.md index 6845cdbd7..9455176c8 100644 --- a/docs/guides/configure-ollama-prompt-cache.md +++ b/docs/guides/configure-ollama-prompt-cache.md @@ -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" } } } diff --git a/docs/guides/configure-openai-compatible-provider.md b/docs/guides/configure-openai-compatible-provider.md index 60345a0b5..e61182ac2 100644 --- a/docs/guides/configure-openai-compatible-provider.md +++ b/docs/guides/configure-openai-compatible-provider.md @@ -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" } } } diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 0015b712f..90c7506e6 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -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, diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 6f4a7cfde..d233bcf12 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -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, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7ef4e7b68..b8b8e00b7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 3a8e5f95d..fde8da91b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -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. diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 9820fdbb7..68e4e94af 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -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() diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 18438d5c6..276f354b3 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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) diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index 0dbc5b827..975aa39c8 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -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, diff --git a/nanobot/webui/settings_services.py b/nanobot/webui/settings_services.py index 1ca30543a..acd8204b7 100644 --- a/nanobot/webui/settings_services.py +++ b/nanobot/webui/settings_services.py @@ -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( diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index b3121569a..470eb05ca 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -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: diff --git a/tests/session/test_session_store.py b/tests/session/test_session_store.py index 8986adc2b..92a1c5ac5 100644 --- a/tests/session/test_session_store.py +++ b/tests/session/test_session_store.py @@ -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() diff --git a/tests/webui/test_session_list_index.py b/tests/webui/test_session_list_index.py index 7e10ea54e..77819596e 100644 --- a/tests/webui/test_session_list_index.py +++ b/tests/webui/test_session_list_index.py @@ -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") diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index b4d249011..1cd63c11a 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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, diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index b77910883..e7dfaaa71 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -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" diff --git a/webui/src/components/settings/models/useModelSettingsActions.ts b/webui/src/components/settings/models/useModelSettingsActions.ts index dae034f8c..9c0c76698 100644 --- a/webui/src/components/settings/models/useModelSettingsActions.ts +++ b/webui/src/components/settings/models/useModelSettingsActions.ts @@ -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: diff --git a/webui/src/components/settings/useSettingsController.ts b/webui/src/components/settings/useSettingsController.ts index 326f380e9..ab8c0d0cf 100644 --- a/webui/src/components/settings/useSettingsController.ts +++ b/webui/src/components/settings/useSettingsController.ts @@ -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 || diff --git a/webui/src/tests/settings-models.test.tsx b/webui/src/tests/settings-models.test.tsx index afd9ea75a..ec54b6a23 100644 --- a/webui/src/tests/settings-models.test.tsx +++ b/webui/src/tests/settings-models.test.tsx @@ -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(() => {}))); + 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 = {