fix(models): support atomic preset renames

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent 3dc38f6396
commit c15e6f2a37
12 changed files with 203 additions and 49 deletions
@@ -3308,7 +3308,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
"settings.model_configuration.update",
{
"name": "fast-writing",
"label": "Codex",
"new_name": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
@@ -3320,24 +3320,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
updated_presets = {
preset["name"]: preset for preset in updated_preset_body["model_presets"]
}
assert updated_presets["fast-writing"]["label"] == "fast-writing"
assert updated_presets["Codex"]["label"] == "Codex"
call_order_updated = await _webui_mutate(
webui_client,
"settings.model_call_order.update",
{"order": ["fast-writing", "deep"]},
{"order": ["Codex", "deep"]},
)
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_preset"] == "Codex"
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
assert call_order_body["model_call_order"] == ["Codex", "deep"]
duplicate_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"name": "codex",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
@@ -3435,10 +3435,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
saved = load_config(config_path)
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.model_preset == "Codex"
assert saved.agents.defaults.fallback_models == ["deep"]
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
assert saved.model_presets["fast-writing"].provider == "openai"
assert saved.model_presets["Codex"].model == "openai/gpt-5.5"
assert saved.model_presets["Codex"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "nanobot"
assert saved.agents.defaults.bot_icon == "🐈"
+40 -2
View File
@@ -792,9 +792,40 @@ def _model_configuration_name(value: str) -> str:
return name
def _model_configuration_name_exists(config: Config, name: str) -> bool:
def _model_configuration_name_exists(
config: Config,
name: str,
*,
exclude: str | None = None,
) -> bool:
normalized = name.casefold()
return any(existing.casefold() == normalized for existing in config.model_presets)
return any(
existing != exclude and existing.casefold() == normalized
for existing in config.model_presets
)
def _rename_model_configuration(config: Config, old_name: str, new_name: str) -> bool:
"""Rename one preset and every config reference to it."""
if old_name == new_name:
return False
if _model_configuration_name_exists(config, new_name, exclude=old_name):
raise WebUISettingsError("configuration already exists", status=409)
config.model_presets = {
(new_name if name == old_name else name): preset
for name, preset in config.model_presets.items()
}
defaults = config.agents.defaults
if defaults.model_preset == old_name:
defaults.model_preset = new_name
defaults.fallback_models = [
new_name if fallback == old_name else fallback
for fallback in defaults.fallback_models
]
if defaults.dream.model_override == old_name:
defaults.dream.model_override = new_name
return True
def _custom_provider_key(config: Config, display_name: str) -> str:
@@ -1139,6 +1170,13 @@ def update_model_configuration(
raise WebUISettingsError("unknown model configuration")
changed = False
new_name_value = query_first_alias(query, "new_name", "newName")
if new_name_value is not None:
new_name = _model_configuration_name(new_name_value)
changed = _rename_model_configuration(config, name, new_name) or changed
name = new_name
preset = config.model_presets[name]
model = query_first(query, "model")
if model is not None:
model = model.strip()
+55
View File
@@ -418,6 +418,61 @@ def test_update_model_configuration_edits_named_preset_without_selecting(
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
def test_update_model_configuration_renames_preset_and_config_references(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"openai": ModelPresetConfig(model="openai/gpt-4.1"),
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4"),
}
defaults = config.agents.defaults
defaults.model_preset = "openai"
defaults.fallback_models = ["backup", "openai"]
defaults.dream.model_override = "openai"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_model_configuration(
{"name": ["openai"], "new_name": ["Codex"]}
)
assert payload["agent"]["model_preset"] == "Codex"
assert payload["model_call_order"] == ["Codex", "backup", "Codex"]
assert [row["name"] for row in payload["model_presets"]] == [
"default",
"Codex",
"backup",
]
saved = load_config(config_path)
assert list(saved.model_presets) == ["Codex", "backup"]
assert saved.agents.defaults.model_preset == "Codex"
assert saved.agents.defaults.fallback_models == ["backup", "Codex"]
assert saved.agents.defaults.dream.model_override == "Codex"
def test_update_model_configuration_rejects_duplicate_rename(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"openai": ModelPresetConfig(model="openai/gpt-4.1"),
"Codex": ModelPresetConfig(model="openai/gpt-5.5"),
}
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError) as duplicate:
update_model_configuration({"name": ["openai"], "new_name": ["codex"]})
assert duplicate.value.status == 409
assert set(load_config(config_path).model_presets) == {"openai", "Codex"}
def test_settings_payload_exposes_named_model_call_order(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -136,6 +136,7 @@ export function SettingsPage({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
@@ -185,6 +186,7 @@ export function SettingsPage({
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
@@ -240,6 +242,7 @@ export function SettingsPage({
token={token}
form={form}
setForm={setForm}
editingPresetName={modelPresetEditingName}
settings={settings}
dirty={modelDirty}
creating={modelPresetCreating}
@@ -256,8 +259,9 @@ export function SettingsPage({
onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation}
onSelectConfiguration={() => {
onSelectConfiguration={(name) => {
setModelPresetCreating(false);
setModelPresetEditingName(name);
modelPresetBeforeCreateRef.current = null;
}}
onDeleteConfiguration={setModelPresetPendingDelete}
@@ -159,6 +159,7 @@ export function ModelsSettings({
token,
form,
setForm,
editingPresetName,
settings,
dirty,
creating,
@@ -181,6 +182,7 @@ export function ModelsSettings({
token: string;
form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
editingPresetName: string;
settings: SettingsPayload;
dirty: boolean;
creating: boolean;
@@ -197,7 +199,7 @@ export function ModelsSettings({
onMigrate: () => void;
onBeginCreate: () => void;
onCancelCreate: () => void;
onSelectConfiguration: () => void;
onSelectConfiguration: (name: string) => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
}) {
const { t } = useTranslation();
@@ -230,7 +232,7 @@ export function ModelsSettings({
preset,
})),
];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null;
const selectedPreset = namedPresetsByName.get(editingPresetName) ?? null;
const activeEditorRowKey =
editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
@@ -263,7 +265,7 @@ export function ModelsSettings({
const modelFieldsMissing =
!form.model.trim() ||
!form.provider.trim() ||
(creating && !form.modelPreset.trim()) ||
!form.modelPreset.trim() ||
form.maxTokens <= 0 ||
form.temperature < 0 ||
form.temperature > 2;
@@ -277,7 +279,7 @@ export function ModelsSettings({
) => {
const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration();
onSelectConfiguration(preset.name);
if (toggleCurrentPreset) {
setEditorOpen((open) => !open);
return;
@@ -348,28 +350,21 @@ export function ModelsSettings({
) : null}
<SettingsRow
title={tx("settings.models.presetName", "Preset name")}
description={
creating
? tx(
"settings.models.presetNameHelp",
"Used everywhere, including /model commands. Names must be unique.",
)
: undefined
}
>
{creating ? (
<Input
autoFocus
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
) : (
<span className="text-[13px] font-medium text-foreground">{form.modelPreset}</span>
description={tx(
"settings.models.presetNameHelp",
"Used everywhere, including /model commands. Names must be unique.",
)}
>
<Input
autoFocus={creating}
aria-label={tx("settings.models.presetName", "Preset name")}
value={form.modelPreset}
placeholder={tx("settings.models.presetNamePlaceholder", "Fast writing")}
onChange={(event) =>
setForm((prev) => ({ ...prev, modelPreset: event.target.value }))
}
className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
/>
</SettingsRow>
<SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker
@@ -90,6 +90,7 @@ export function useModelSettingsActions({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
@@ -105,6 +106,7 @@ export function useModelSettingsActions({
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
@@ -158,6 +160,7 @@ export function useModelSettingsActions({
applyPayload(payload);
if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset));
setModelPresetEditingName(createdPreset);
}
let finalPayload = payload;
@@ -168,6 +171,7 @@ export function useModelSettingsActions({
}
if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset));
setModelPresetEditingName(createdPreset);
}
modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null);
@@ -182,14 +186,16 @@ export function useModelSettingsActions({
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
);
if (!selectedPreset) return;
const nextName = form.modelPreset.trim();
const reasoningEffort = form.reasoningEffort || null;
setSaving(true);
try {
const payload = await updateModelConfiguration(client, {
name: selectedPreset.name,
newName: nextName !== selectedPreset.name ? nextName : undefined,
model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens:
@@ -205,7 +211,8 @@ export function useModelSettingsActions({
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name));
setForm(agentDraftFromPayload(payload, nextName));
setModelPresetEditingName(nextName);
onModelNameChange(payload.agent.model || null);
setError(null);
} catch (err) {
@@ -227,7 +234,7 @@ export function useModelSettingsActions({
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ??
"";
modelPresetBeforeCreateRef.current = form.modelPreset;
modelPresetBeforeCreateRef.current = modelPresetEditingName;
setForm((prev) => ({
...prev,
modelPreset: "",
@@ -248,6 +255,7 @@ export function useModelSettingsActions({
const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
modelPresetBeforeCreateRef.current = null;
};
@@ -9,6 +9,9 @@ import type { ProviderForm } from "@/components/settings/models/ProviderSettings
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const initialForm = initialSettings
? agentDraftFromPayload(initialSettings)
: DEFAULT_AGENT_SETTINGS_DRAFT;
const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = useState(false);
@@ -28,8 +31,9 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<AgentSettingsDraft>(() =>
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT,
const [form, setForm] = useState<AgentSettingsDraft>(initialForm);
const [modelPresetEditingName, setModelPresetEditingName] = useState(
initialForm.modelPreset,
);
const [modelCallOrder, setModelCallOrder] = useState<string[]>(
() => initialSettings?.model_call_order ?? [],
@@ -45,6 +49,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
providerForms,
providerOAuthCompleting,
@@ -62,6 +67,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
setModelConfigurationSaving,
setModelMigrationSaving,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms,
setProviderOAuthCompleting,
@@ -93,10 +93,12 @@ export function useSettingsController({
const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting,
modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete,
providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete,
setModelCallOrder, setModelPresetCreating, setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
} = modelState;
@@ -147,7 +149,9 @@ export function useSettingsController({
) => {
setSettings(payload);
if (!options.preserveAgentForm) {
setForm(agentDraftFromPayload(payload));
const nextForm = agentDraftFromPayload(payload);
setForm(nextForm);
setModelPresetEditingName(nextForm.modelPreset);
setModelPresetCreating(false);
}
setModelCallOrder(payload.model_call_order ?? []);
@@ -252,10 +256,11 @@ export function useSettingsController({
const modelDirty = useMemo(() => {
if (!settings) return false;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => !preset.is_default && preset.name === modelPresetEditingName,
);
if (!selectedPreset) return false;
return (
form.modelPreset.trim() !== selectedPreset.name ||
form.model !== selectedPreset.model ||
form.provider !== selectedPreset.provider ||
form.maxTokens !== selectedPreset.max_tokens ||
@@ -263,7 +268,7 @@ export function useSettingsController({
form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "")
);
}, [form, settings]);
}, [form, modelPresetEditingName, settings]);
const imageGenerationDirty = useMemo(() => {
if (!settings) return false;
@@ -532,6 +537,7 @@ export function useSettingsController({
modelMigrationSaving,
modelPresetBeforeCreateRef,
modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete,
nanobotFeatureAction,
nanobotFeatureConfirm,
@@ -581,6 +587,7 @@ export function useSettingsController({
setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl,
setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete,
setNanobotFeatureConfirm,
setNanobotFeatures,
+1
View File
@@ -927,6 +927,7 @@ export async function updateModelConfiguration(
"settings.model_configuration.update",
{
name: configuration.name,
...(configuration.newName !== undefined ? { new_name: configuration.newName } : {}),
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
...(configuration.model !== undefined ? { model: configuration.model } : {}),
...modelGenerationSettingsPayload(configuration),
+1
View File
@@ -1097,6 +1097,7 @@ export interface ModelConfigurationCreate {
export interface ModelConfigurationUpdate {
name: string;
newName?: string;
provider?: string;
model?: string;
maxTokens?: number;
+2
View File
@@ -466,6 +466,7 @@ describe("webui API helpers", () => {
it("serializes model configuration updates", async () => {
await updateModelConfiguration(mutationTransport, {
name: "codex",
newName: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
maxTokens: 8192,
@@ -478,6 +479,7 @@ describe("webui API helpers", () => {
"settings.model_configuration.update",
{
name: "codex",
new_name: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
max_tokens: 8192,
+39 -2
View File
@@ -106,10 +106,45 @@ describe("Settings models", () => {
const editor = screen.getByTestId("model-preset-editor");
expect(within(editor).getByText("Preset name")).toBeInTheDocument();
expect(within(editor).getByText("openai")).toBeInTheDocument();
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"openai",
);
expect(within(editor).queryByText("minimax")).not.toBeInTheDocument();
});
it("renames an existing preset without losing the editor selection", async () => {
const payload = settingsPayload();
const renamedPayload: SettingsPayload = {
...payload,
agent: { ...payload.agent, model_preset: "Codex" },
model_presets: payload.model_presets.map((preset) => ({
...preset,
name: "Codex",
label: "Codex",
})),
model_call_order: ["Codex"],
};
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
requestMutationMock.mockResolvedValueOnce(renamedPayload);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.model_configuration.update",
{ name: "primary", new_name: "Codex" },
20_000,
);
});
expect(await screen.findByTestId("model-call-order-row-Codex")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Preset name" })).toHaveValue("Codex");
});
it("keeps generation parameters collapsed until advanced options are opened", async () => {
vi.stubGlobal(
"fetch",
@@ -187,7 +222,9 @@ describe("Settings models", () => {
"lg:max-w-6xl",
"rounded-floating",
);
expect(within(editor).getByText("primary")).toBeInTheDocument();
expect(within(editor).getByRole("textbox", { name: "Preset name" })).toHaveValue(
"primary",
);
const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");