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", "settings.model_configuration.update",
{ {
"name": "fast-writing", "name": "fast-writing",
"label": "Codex", "new_name": "Codex",
"provider": "openai", "provider": "openai",
"model": "openai/gpt-5.5", "model": "openai/gpt-5.5",
}, },
@@ -3320,24 +3320,24 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
updated_presets = { updated_presets = {
preset["name"]: preset for preset in updated_preset_body["model_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( call_order_updated = await _webui_mutate(
webui_client, webui_client,
"settings.model_call_order.update", "settings.model_call_order.update",
{"order": ["fast-writing", "deep"]}, {"order": ["Codex", "deep"]},
) )
assert call_order_updated.status_code == 200 assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json() 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["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( duplicate_preset = await _webui_mutate(
webui_client, webui_client,
"settings.model_configuration.create", "settings.model_configuration.create",
{ {
"label": "Fast writing", "name": "codex",
"provider": "openai", "provider": "openai",
"model": "openai/gpt-4.1-mini", "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) saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat" 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.agents.defaults.fallback_models == ["deep"]
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5" assert saved.model_presets["Codex"].model == "openai/gpt-5.5"
assert saved.model_presets["fast-writing"].provider == "openai" assert saved.model_presets["Codex"].provider == "openai"
assert saved.agents.defaults.timezone == "Asia/Shanghai" assert saved.agents.defaults.timezone == "Asia/Shanghai"
assert saved.agents.defaults.bot_name == "nanobot" assert saved.agents.defaults.bot_name == "nanobot"
assert saved.agents.defaults.bot_icon == "🐈" assert saved.agents.defaults.bot_icon == "🐈"
+40 -2
View File
@@ -792,9 +792,40 @@ def _model_configuration_name(value: str) -> str:
return name 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() 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: def _custom_provider_key(config: Config, display_name: str) -> str:
@@ -1139,6 +1170,13 @@ def update_model_configuration(
raise WebUISettingsError("unknown model configuration") raise WebUISettingsError("unknown model configuration")
changed = False 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") model = query_first(query, "model")
if model is not None: if model is not None:
model = model.strip() 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" 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( def test_settings_payload_exposes_named_model_call_order(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -136,6 +136,7 @@ export function SettingsPage({
modelMigrationSaving, modelMigrationSaving,
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete, modelPresetPendingDelete,
nanobotFeatureAction, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatureConfirm,
@@ -185,6 +186,7 @@ export function SettingsPage({
setMcpOAuthCallbackError, setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl, setMcpOAuthCallbackUrl,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setNanobotFeatureConfirm, setNanobotFeatureConfirm,
setNanobotFeatures, setNanobotFeatures,
@@ -240,6 +242,7 @@ export function SettingsPage({
token={token} token={token}
form={form} form={form}
setForm={setForm} setForm={setForm}
editingPresetName={modelPresetEditingName}
settings={settings} settings={settings}
dirty={modelDirty} dirty={modelDirty}
creating={modelPresetCreating} creating={modelPresetCreating}
@@ -256,8 +259,9 @@ export function SettingsPage({
onMigrate={handleMigrateModelConfigurations} onMigrate={handleMigrateModelConfigurations}
onBeginCreate={beginModelPresetCreation} onBeginCreate={beginModelPresetCreation}
onCancelCreate={cancelModelPresetCreation} onCancelCreate={cancelModelPresetCreation}
onSelectConfiguration={() => { onSelectConfiguration={(name) => {
setModelPresetCreating(false); setModelPresetCreating(false);
setModelPresetEditingName(name);
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
}} }}
onDeleteConfiguration={setModelPresetPendingDelete} onDeleteConfiguration={setModelPresetPendingDelete}
@@ -159,6 +159,7 @@ export function ModelsSettings({
token, token,
form, form,
setForm, setForm,
editingPresetName,
settings, settings,
dirty, dirty,
creating, creating,
@@ -181,6 +182,7 @@ export function ModelsSettings({
token: string; token: string;
form: AgentSettingsDraft; form: AgentSettingsDraft;
setForm: Dispatch<SetStateAction<AgentSettingsDraft>>; setForm: Dispatch<SetStateAction<AgentSettingsDraft>>;
editingPresetName: string;
settings: SettingsPayload; settings: SettingsPayload;
dirty: boolean; dirty: boolean;
creating: boolean; creating: boolean;
@@ -197,7 +199,7 @@ export function ModelsSettings({
onMigrate: () => void; onMigrate: () => void;
onBeginCreate: () => void; onBeginCreate: () => void;
onCancelCreate: () => void; onCancelCreate: () => void;
onSelectConfiguration: () => void; onSelectConfiguration: (name: string) => void;
onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void; onDeleteConfiguration: (preset: SettingsPayload["model_presets"][number]) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -230,7 +232,7 @@ export function ModelsSettings({
preset, preset,
})), })),
]; ];
const selectedPreset = namedPresetsByName.get(form.modelPreset) ?? null; const selectedPreset = namedPresetsByName.get(editingPresetName) ?? null;
const activeEditorRowKey = const activeEditorRowKey =
editorRowKey ?? editorRowKey ??
presetRows.find((row) => row.name === selectedPreset?.name)?.key ?? presetRows.find((row) => row.name === selectedPreset?.name)?.key ??
@@ -263,7 +265,7 @@ export function ModelsSettings({
const modelFieldsMissing = const modelFieldsMissing =
!form.model.trim() || !form.model.trim() ||
!form.provider.trim() || !form.provider.trim() ||
(creating && !form.modelPreset.trim()) || !form.modelPreset.trim() ||
form.maxTokens <= 0 || form.maxTokens <= 0 ||
form.temperature < 0 || form.temperature < 0 ||
form.temperature > 2; form.temperature > 2;
@@ -277,7 +279,7 @@ export function ModelsSettings({
) => { ) => {
const toggleCurrentPreset = const toggleCurrentPreset =
!creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey; !creating && selectedPreset?.name === preset.name && activeEditorRowKey === rowKey;
onSelectConfiguration(); onSelectConfiguration(preset.name);
if (toggleCurrentPreset) { if (toggleCurrentPreset) {
setEditorOpen((open) => !open); setEditorOpen((open) => !open);
return; return;
@@ -348,28 +350,21 @@ export function ModelsSettings({
) : null} ) : null}
<SettingsRow <SettingsRow
title={tx("settings.models.presetName", "Preset name")} title={tx("settings.models.presetName", "Preset name")}
description={ description={tx(
creating "settings.models.presetNameHelp",
? tx( "Used everywhere, including /model commands. Names must be unique.",
"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>
)} )}
>
<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>
<SettingsRow title={t("settings.rows.provider")}> <SettingsRow title={t("settings.rows.provider")}>
<ProviderPicker <ProviderPicker
@@ -90,6 +90,7 @@ export function useModelSettingsActions({
modelMigrationSaving, modelMigrationSaving,
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete, modelPresetPendingDelete,
providerForms, providerForms,
providerOAuthCompleting, providerOAuthCompleting,
@@ -105,6 +106,7 @@ export function useModelSettingsActions({
setModelConfigurationSaving, setModelConfigurationSaving,
setModelMigrationSaving, setModelMigrationSaving,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setProviderForms, setProviderForms,
setProviderOAuthCompleting, setProviderOAuthCompleting,
@@ -158,6 +160,7 @@ export function useModelSettingsActions({
applyPayload(payload); applyPayload(payload);
if (createdPreset) { if (createdPreset) {
setForm(agentDraftFromPayload(payload, createdPreset)); setForm(agentDraftFromPayload(payload, createdPreset));
setModelPresetEditingName(createdPreset);
} }
let finalPayload = payload; let finalPayload = payload;
@@ -168,6 +171,7 @@ export function useModelSettingsActions({
} }
if (createdPreset) { if (createdPreset) {
setForm(agentDraftFromPayload(finalPayload, createdPreset)); setForm(agentDraftFromPayload(finalPayload, createdPreset));
setModelPresetEditingName(createdPreset);
} }
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
onModelNameChange(finalPayload.agent.model || null); onModelNameChange(finalPayload.agent.model || null);
@@ -182,14 +186,16 @@ export function useModelSettingsActions({
if (!modelDirty) return; if (!modelDirty) return;
const selectedPreset = settings.model_presets.find( const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset, (preset) => !preset.is_default && preset.name === modelPresetEditingName,
); );
if (!selectedPreset) return; if (!selectedPreset) return;
const nextName = form.modelPreset.trim();
const reasoningEffort = form.reasoningEffort || null; const reasoningEffort = form.reasoningEffort || null;
setSaving(true); setSaving(true);
try { try {
const payload = await updateModelConfiguration(client, { const payload = await updateModelConfiguration(client, {
name: selectedPreset.name, name: selectedPreset.name,
newName: nextName !== selectedPreset.name ? nextName : undefined,
model: form.model !== selectedPreset.model ? form.model : undefined, model: form.model !== selectedPreset.model ? form.model : undefined,
provider: form.provider !== selectedPreset.provider ? form.provider : undefined, provider: form.provider !== selectedPreset.provider ? form.provider : undefined,
maxTokens: maxTokens:
@@ -205,7 +211,8 @@ export function useModelSettingsActions({
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined, reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
}); });
applyPayload(payload); applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name)); setForm(agentDraftFromPayload(payload, nextName));
setModelPresetEditingName(nextName);
onModelNameChange(payload.agent.model || null); onModelNameChange(payload.agent.model || null);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -227,7 +234,7 @@ export function useModelSettingsActions({
configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ?? configuredModelProviderOptions.find((option) => option.name === currentProvider)?.name ??
configuredModelProviderOptions[0]?.name ?? configuredModelProviderOptions[0]?.name ??
""; "";
modelPresetBeforeCreateRef.current = form.modelPreset; modelPresetBeforeCreateRef.current = modelPresetEditingName;
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
modelPreset: "", modelPreset: "",
@@ -248,6 +255,7 @@ export function useModelSettingsActions({
const previousPreset = modelPresetBeforeCreateRef.current; const previousPreset = modelPresetBeforeCreateRef.current;
setModelPresetCreating(false); setModelPresetCreating(false);
setForm(agentDraftFromPayload(settings, previousPreset ?? undefined)); setForm(agentDraftFromPayload(settings, previousPreset ?? undefined));
setModelPresetEditingName(previousPreset ?? agentDraftFromPayload(settings).modelPreset);
modelPresetBeforeCreateRef.current = null; modelPresetBeforeCreateRef.current = null;
}; };
@@ -9,6 +9,9 @@ import type { ProviderForm } from "@/components/settings/models/ProviderSettings
import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types"; import type { ProviderOAuthAuthorizationRequired, SettingsPayload } from "@/lib/types";
export function useModelSettingsState(initialSettings: SettingsPayload | null) { export function useModelSettingsState(initialSettings: SettingsPayload | null) {
const initialForm = initialSettings
? agentDraftFromPayload(initialSettings)
: DEFAULT_AGENT_SETTINGS_DRAFT;
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [modelPresetCreating, setModelPresetCreating] = useState(false); const [modelPresetCreating, setModelPresetCreating] = useState(false);
const [modelConfigurationSaving, setModelConfigurationSaving] = 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 [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({}); const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [form, setForm] = useState<AgentSettingsDraft>(() => const [form, setForm] = useState<AgentSettingsDraft>(initialForm);
initialSettings ? agentDraftFromPayload(initialSettings) : DEFAULT_AGENT_SETTINGS_DRAFT, const [modelPresetEditingName, setModelPresetEditingName] = useState(
initialForm.modelPreset,
); );
const [modelCallOrder, setModelCallOrder] = useState<string[]>( const [modelCallOrder, setModelCallOrder] = useState<string[]>(
() => initialSettings?.model_call_order ?? [], () => initialSettings?.model_call_order ?? [],
@@ -45,6 +49,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
modelMigrationSaving, modelMigrationSaving,
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete, modelPresetPendingDelete,
providerForms, providerForms,
providerOAuthCompleting, providerOAuthCompleting,
@@ -62,6 +67,7 @@ export function useModelSettingsState(initialSettings: SettingsPayload | null) {
setModelConfigurationSaving, setModelConfigurationSaving,
setModelMigrationSaving, setModelMigrationSaving,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setProviderForms, setProviderForms,
setProviderOAuthCompleting, setProviderOAuthCompleting,
@@ -93,10 +93,12 @@ export function useSettingsController({
const { const {
editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving, editingProviderKeys, expandedProvider, form, modelCallOrder, modelCallOrderSaving,
modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef, modelConfigurationSaving, modelMigrationSaving, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetPendingDelete, providerForms, providerOAuthCompleting, modelPresetCreating, modelPresetEditingName, modelPresetPendingDelete,
providerForms, providerOAuthCompleting,
providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse, providerOAuthDialogError, providerOAuthFlow, providerOAuthFlowRef, providerOAuthResponse,
providerSaving, saving, setForm, providerSaving, saving, setForm,
setModelCallOrder, setModelPresetCreating, setModelPresetPendingDelete, setModelCallOrder, setModelPresetCreating, setModelPresetEditingName,
setModelPresetPendingDelete,
setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError, setProviderForms, setProviderOAuthCompleting, setProviderOAuthDialogError,
setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys, setProviderOAuthFlow, setProviderOAuthResponse, visibleProviderKeys,
} = modelState; } = modelState;
@@ -147,7 +149,9 @@ export function useSettingsController({
) => { ) => {
setSettings(payload); setSettings(payload);
if (!options.preserveAgentForm) { if (!options.preserveAgentForm) {
setForm(agentDraftFromPayload(payload)); const nextForm = agentDraftFromPayload(payload);
setForm(nextForm);
setModelPresetEditingName(nextForm.modelPreset);
setModelPresetCreating(false); setModelPresetCreating(false);
} }
setModelCallOrder(payload.model_call_order ?? []); setModelCallOrder(payload.model_call_order ?? []);
@@ -252,10 +256,11 @@ export function useSettingsController({
const modelDirty = useMemo(() => { const modelDirty = useMemo(() => {
if (!settings) return false; if (!settings) return false;
const selectedPreset = settings.model_presets.find( 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; if (!selectedPreset) return false;
return ( return (
form.modelPreset.trim() !== selectedPreset.name ||
form.model !== selectedPreset.model || form.model !== selectedPreset.model ||
form.provider !== selectedPreset.provider || form.provider !== selectedPreset.provider ||
form.maxTokens !== selectedPreset.max_tokens || form.maxTokens !== selectedPreset.max_tokens ||
@@ -263,7 +268,7 @@ export function useSettingsController({
form.temperature !== selectedPreset.temperature || form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "")
); );
}, [form, settings]); }, [form, modelPresetEditingName, settings]);
const imageGenerationDirty = useMemo(() => { const imageGenerationDirty = useMemo(() => {
if (!settings) return false; if (!settings) return false;
@@ -532,6 +537,7 @@ export function useSettingsController({
modelMigrationSaving, modelMigrationSaving,
modelPresetBeforeCreateRef, modelPresetBeforeCreateRef,
modelPresetCreating, modelPresetCreating,
modelPresetEditingName,
modelPresetPendingDelete, modelPresetPendingDelete,
nanobotFeatureAction, nanobotFeatureAction,
nanobotFeatureConfirm, nanobotFeatureConfirm,
@@ -581,6 +587,7 @@ export function useSettingsController({
setMcpOAuthCallbackError, setMcpOAuthCallbackError,
setMcpOAuthCallbackUrl, setMcpOAuthCallbackUrl,
setModelPresetCreating, setModelPresetCreating,
setModelPresetEditingName,
setModelPresetPendingDelete, setModelPresetPendingDelete,
setNanobotFeatureConfirm, setNanobotFeatureConfirm,
setNanobotFeatures, setNanobotFeatures,
+1
View File
@@ -927,6 +927,7 @@ export async function updateModelConfiguration(
"settings.model_configuration.update", "settings.model_configuration.update",
{ {
name: configuration.name, name: configuration.name,
...(configuration.newName !== undefined ? { new_name: configuration.newName } : {}),
...(configuration.provider !== undefined ? { provider: configuration.provider } : {}), ...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
...(configuration.model !== undefined ? { model: configuration.model } : {}), ...(configuration.model !== undefined ? { model: configuration.model } : {}),
...modelGenerationSettingsPayload(configuration), ...modelGenerationSettingsPayload(configuration),
+1
View File
@@ -1097,6 +1097,7 @@ export interface ModelConfigurationCreate {
export interface ModelConfigurationUpdate { export interface ModelConfigurationUpdate {
name: string; name: string;
newName?: string;
provider?: string; provider?: string;
model?: string; model?: string;
maxTokens?: number; maxTokens?: number;
+2
View File
@@ -466,6 +466,7 @@ describe("webui API helpers", () => {
it("serializes model configuration updates", async () => { it("serializes model configuration updates", async () => {
await updateModelConfiguration(mutationTransport, { await updateModelConfiguration(mutationTransport, {
name: "codex", name: "codex",
newName: "Codex",
provider: "openai_codex", provider: "openai_codex",
model: "openai-codex/gpt-5.5", model: "openai-codex/gpt-5.5",
maxTokens: 8192, maxTokens: 8192,
@@ -478,6 +479,7 @@ describe("webui API helpers", () => {
"settings.model_configuration.update", "settings.model_configuration.update",
{ {
name: "codex", name: "codex",
new_name: "Codex",
provider: "openai_codex", provider: "openai_codex",
model: "openai-codex/gpt-5.5", model: "openai-codex/gpt-5.5",
max_tokens: 8192, max_tokens: 8192,
+39 -2
View File
@@ -106,10 +106,45 @@ describe("Settings models", () => {
const editor = screen.getByTestId("model-preset-editor"); const editor = screen.getByTestId("model-preset-editor");
expect(within(editor).getByText("Preset name")).toBeInTheDocument(); 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(); 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 () => { it("keeps generation parameters collapsed until advanced options are opened", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -187,7 +222,9 @@ describe("Settings models", () => {
"lg:max-w-6xl", "lg:max-w-6xl",
"rounded-floating", "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" }); const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled(); expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint"); expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint");