fix(models): preserve preset rename compatibility

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent a956fe1d60
commit 0a6ee1c539
20 changed files with 307 additions and 46 deletions
+1 -1
View File
@@ -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.
+5 -7
View File
@@ -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
+2 -3
View File
@@ -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"
}
}
}
+3
View File
@@ -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,
+4
View File
@@ -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,
+4 -12
View File
@@ -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:
+42
View File
@@ -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.
+10 -1
View File
@@ -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()
+22 -4
View File
@@ -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)
+10 -1
View File
@@ -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,
+11 -1
View File
@@ -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(
+17 -8
View File
@@ -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:
+33
View File
@@ -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()
+14
View File
@@ -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")
+43 -1
View File
@@ -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,
+39 -1
View File
@@ -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 ||
+41
View File
@@ -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 = {