Preserve model preset inherited settings

Deep-merge model preset slots with the active configuration so custom context windows, rate limits, and nested kwargs survive preset switches.

Treat legacy utility preset defaults as implicit values, allow omitted utility and embedding slots to inherit configured models, and document the partial-preset behavior.
This commit is contained in:
Alessandro 2026-05-18 02:45:08 +02:00
parent 82280950ea
commit e0337410e7
9 changed files with 487 additions and 50 deletions

View file

@ -39,6 +39,11 @@ Think of a preset as a label on a model setup.
| **Main model** | The model that does the main conversation and reasoning. |
| **Utility model** | A smaller helper model for lighter internal tasks. |
Presets can be partial. If a preset does not set a utility model, Agent Zero
uses your configured Utility Model. If a preset changes the utility model but
does not set advanced fields, your configured context window, rate limits, and
other advanced values stay in place.
## Add A Preset
Click **Add Preset**, give it a name, choose models, then click **Save Presets**.

View file

@ -223,7 +223,16 @@ def resolve_browser_model_selection(
if preset_name:
preset = model_config.get_preset_by_name(preset_name)
if isinstance(preset, dict):
chat_cfg = preset.get("chat", {})
if hasattr(model_config, "build_config_from_preset"):
preset_config = model_config.build_config_from_preset(
preset,
model_config.get_config(agent) if hasattr(model_config, "get_config") else {},
strip_api_key=False,
slots=("chat",),
)
chat_cfg = preset_config.get("chat_model", {})
else:
chat_cfg = preset.get("chat", {})
if isinstance(chat_cfg, dict) and (
str(chat_cfg.get("provider", "") or "").strip()
or str(chat_cfg.get("name", "") or "").strip()

View file

@ -75,12 +75,9 @@ The project preset file uses the same plain YAML list schema as global presets.
utility:
provider: openrouter
name: openai/gpt-5.4-mini
api_base: ""
ctx_length: 128000
ctx_input: 0.7
```
Selecting a preset for a project copies the preset's `chat` and optional `utility` settings into the project's `config.json`. The embedding model is copied from the current effective config, because presets currently define chat and utility only.
Preset slots are partial overlays. Missing fields inherit from the current effective config, so a preset can switch only the model identity while preserving tuned context windows, rate limits, and nested `kwargs`. The `utility` and `embedding` slots are optional and only apply when they declare a provider or model name; otherwise those configured models are inherited. Selecting a preset for a project writes the merged result into the project's `config.json`.
## Plugin Metadata

View file

@ -10,10 +10,6 @@
utility:
provider: "openrouter"
name: "openai/gpt-5.4-mini"
api_key: ""
api_base: ""
ctx_length: 128000
ctx_input: 0.7
- name: "Balance"
chat:
provider: "openrouter"
@ -26,10 +22,6 @@
utility:
provider: "openrouter"
name: "google/gemini-3.1-flash-lite-preview"
api_key: ""
api_base: ""
ctx_length: 128000
ctx_input: 0.7
- name: "Cost Efficient"
chat:
provider: "openrouter"
@ -42,7 +34,3 @@
utility:
provider: "openrouter"
name: "openai/gpt-5.4-nano"
api_key: ""
api_base: ""
ctx_length: 128000
ctx_input: 0.7

View file

@ -11,6 +11,26 @@ DEFAULT_PRESETS_FILE = "default_presets.yaml"
PROVIDER_METADATA_FILE = "provider_metadata.yaml"
PRESET_SCOPE_GLOBAL = "global"
PRESET_SCOPE_PROJECT = "project"
PRESET_SLOT_CONFIG_SECTIONS = {
"chat": "chat_model",
"utility": "utility_model",
"embedding": "embedding_model",
}
IMPLICIT_PRESET_SLOT_DEFAULTS = {
"utility": {
"ctx_length": 128000,
"ctx_input": 0.7,
"rl_requests": 0,
"rl_input": 0,
"rl_output": 0,
"kwargs": {},
},
"embedding": {
"rl_requests": 0,
"rl_input": 0,
"kwargs": {},
},
}
LOCAL_PROVIDERS = {"ollama", "lm_studio"}
LOCAL_EMBEDDING = {"huggingface"}
_PROVIDER_METADATA_CACHE: dict | None = None
@ -96,14 +116,33 @@ def _strip_ui_fields(value: dict, *, strip_api_key: bool) -> dict:
return cleaned
def _preset_default_values_equal(value, default) -> bool:
if isinstance(default, float):
try:
return float(value) == default
except (TypeError, ValueError):
return False
return value == default
def _strip_implicit_preset_defaults(slot: str, slot_config: dict) -> dict:
cleaned = deepcopy(slot_config)
defaults = IMPLICIT_PRESET_SLOT_DEFAULTS.get(slot, {})
for key, default in defaults.items():
if key in cleaned and _preset_default_values_equal(cleaned[key], default):
cleaned.pop(key, None)
return cleaned
def _clean_preset_for_file(preset: dict) -> dict:
cleaned = {
"name": str(preset.get("name", "") or ""),
}
for slot in ("chat", "utility"):
for slot in PRESET_SLOT_CONFIG_SECTIONS:
slot_config = preset.get(slot)
if isinstance(slot_config, dict):
cleaned[slot] = _strip_ui_fields(slot_config, strip_api_key=False)
slot_clean = _strip_ui_fields(slot_config, strip_api_key=False)
cleaned[slot] = _strip_implicit_preset_defaults(slot, slot_clean)
return cleaned
@ -230,17 +269,115 @@ def get_preset_by_name(
return resolve_preset(name, scope=scope, project_name=project_name)
def build_config_from_preset(preset: dict, base_config: dict) -> dict:
"""Copy chat/utility settings from a preset into a standalone model config."""
config = normalize_config_for_save(base_config)
def _deep_merge_dict(base: dict, override: dict) -> dict:
"""Recursively overlay override onto base without mutating either input."""
result = deepcopy(base) if isinstance(base, dict) else {}
for key, value in override.items():
if (
isinstance(value, dict)
and isinstance(result.get(key), dict)
):
result[key] = _deep_merge_dict(result[key], value)
else:
result[key] = deepcopy(value)
return result
chat = preset.get("chat") if isinstance(preset, dict) else None
if isinstance(chat, dict):
config["chat_model"] = _strip_ui_fields(chat, strip_api_key=True)
utility = preset.get("utility") if isinstance(preset, dict) else None
if isinstance(utility, dict) and (utility.get("provider") or utility.get("name")):
config["utility_model"] = _strip_ui_fields(utility, strip_api_key=True)
def _slot_has_identity(slot_config: dict) -> bool:
return bool(slot_config.get("provider") or slot_config.get("name"))
def _get_preset_slot_config(preset: dict, slot: str) -> dict | None:
"""Return the preset payload for a slot.
Legacy raw overrides store the main/chat model directly at the top level,
while named presets store it under the "chat" key.
"""
if not isinstance(preset, dict):
return None
slot_config = preset.get(slot)
if isinstance(slot_config, dict):
return slot_config
if slot == "chat" and not any(key in preset for key in PRESET_SLOT_CONFIG_SECTIONS):
if _slot_has_identity(preset):
return preset
return None
def _should_apply_preset_slot(slot: str, slot_config: dict | None) -> bool:
if not isinstance(slot_config, dict):
return False
cleaned = _strip_implicit_preset_defaults(
slot,
_strip_ui_fields(slot_config, strip_api_key=False),
)
meaningful = {
key: value
for key, value in cleaned.items()
if key != "api_key"
}
if not meaningful:
return False
# Slots inherit the configured model unless the preset declares a model
# identity for that slot. This keeps empty UI placeholders from accidentally
# overriding context/rate-limit settings.
return _slot_has_identity(cleaned)
def _merge_model_slot(
slot: str,
base_slot: dict,
preset_slot: dict,
*,
strip_api_key: bool,
) -> dict:
cleaned = _strip_implicit_preset_defaults(
slot,
_strip_ui_fields(preset_slot, strip_api_key=strip_api_key),
)
if not strip_api_key and not str(cleaned.get("api_key") or "").strip():
cleaned.pop("api_key", None)
return _deep_merge_dict(base_slot if isinstance(base_slot, dict) else {}, cleaned)
def build_config_from_preset(
preset: dict,
base_config: dict,
*,
strip_api_key: bool = True,
slots: tuple[str, ...] | None = None,
) -> dict:
"""Overlay preset settings onto a standalone model config.
Presets are intentionally partial: omitted fields inherit from the current
config, so selecting a preset does not reset tuned values such as context
windows, rate limits, or nested kwargs unless the preset explicitly defines
them.
"""
config = (
normalize_config_for_save(base_config)
if strip_api_key
else deepcopy(base_config or {})
)
for slot in slots or tuple(PRESET_SLOT_CONFIG_SECTIONS):
section = PRESET_SLOT_CONFIG_SECTIONS.get(slot)
if not section:
continue
slot_config = _get_preset_slot_config(preset, slot)
if not _should_apply_preset_slot(slot, slot_config):
continue
config[section] = _merge_model_slot(
slot,
config.get(section, {}),
slot_config,
strip_api_key=strip_api_key,
)
return config
@ -269,24 +406,31 @@ def _resolve_override(agent) -> dict | None:
def get_chat_model_config(agent=None) -> dict:
"""Get chat model config, with per-chat override if active."""
cfg = get_config(agent)
override = _resolve_override(agent)
if override:
# Preset has a nested 'chat' key; raw override is flat
chat_cfg = override.get("chat", override)
if chat_cfg.get("provider") or chat_cfg.get("name"):
return chat_cfg
cfg = get_config(agent)
config = build_config_from_preset(
override,
cfg,
strip_api_key=False,
slots=("chat",),
)
return config.get("chat_model", {})
return cfg.get("chat_model", {})
def get_utility_model_config(agent=None) -> dict:
"""Get utility model config, with per-chat override if active."""
cfg = get_config(agent)
override = _resolve_override(agent)
if override:
util_cfg = override.get("utility", {})
if util_cfg.get("provider") or util_cfg.get("name"):
return util_cfg
cfg = get_config(agent)
config = build_config_from_preset(
override,
cfg,
strip_api_key=False,
slots=("utility",),
)
return config.get("utility_model", {})
return cfg.get("utility_model", {})

View file

@ -65,8 +65,8 @@
@click="
presets = [...presets, {
name: 'Preset ' + (presets.length + 1),
chat: { provider: '', name: '', api_key: '', api_base: '', ctx_length: 128000, ctx_history: 0.7, vision: true, rl_requests: 0, rl_input: 0, rl_output: 0, kwargs: {}, _kwargs_text: '' },
utility: { provider: '', name: '', api_key: '', api_base: '', ctx_length: 128000, ctx_input: 0.7, rl_requests: 0, rl_input: 0, rl_output: 0, kwargs: {}, _kwargs_text: '' }
chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' }
}]">
<span class="material-symbols-outlined">add</span>
<span>Add Preset</span>

View file

@ -46,6 +46,99 @@ export function textToHeaders(text) {
return d;
}
function clonePlain(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function isBlankPresetValue(value) {
if (value === undefined || value === null || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
const IMPLICIT_PRESET_SLOT_DEFAULTS = {
utility: {
ctx_length: 128000,
ctx_input: 0.7,
rl_requests: 0,
rl_input: 0,
rl_output: 0,
kwargs: {},
},
embedding: {
rl_requests: 0,
rl_input: 0,
kwargs: {},
},
};
function presetDefaultValuesEqual(value, defaultValue) {
if (typeof defaultValue === 'number') return Number(value) === defaultValue;
return JSON.stringify(value) === JSON.stringify(defaultValue);
}
function cleanPresetSlot(slot, stripApiKey = true, slotKey = '') {
const clean = {};
const implicitDefaults = IMPLICIT_PRESET_SLOT_DEFAULTS[slotKey] || {};
for (const [key, value] of Object.entries(slot || {})) {
if (key.startsWith('_')) continue;
if (stripApiKey && key === 'api_key') continue;
if (key === 'api_base' && value === '') {
clean[key] = value;
continue;
}
if (key === 'kwargs' && isBlankPresetValue(value)) continue;
if (isBlankPresetValue(value)) continue;
if (key in implicitDefaults && presetDefaultValuesEqual(value, implicitDefaults[key])) continue;
clean[key] = value;
}
return clean;
}
function hasModelIdentity(slot) {
return !!(slot?.provider || slot?.name);
}
export function mergeModelSlot(baseSlot, presetSlot, stripApiKey = true, slotKey = '') {
const result = clonePlain(baseSlot || {});
const clean = cleanPresetSlot(presetSlot, stripApiKey, slotKey);
for (const [key, value] of Object.entries(clean)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[key] &&
typeof result[key] === 'object' &&
!Array.isArray(result[key])
) {
result[key] = mergeModelSlot(result[key], value, false);
} else {
result[key] = clonePlain(value);
}
}
return result;
}
export function configFromPreset(preset, baseConfig, stripApiKey = true) {
const config = clonePlain(baseConfig || {});
const slots = [
['chat', 'chat_model'],
['utility', 'utility_model'],
['embedding', 'embedding_model'],
];
for (const [slotKey, sectionKey] of slots) {
const slot = preset?.[slotKey];
if (!slot || typeof slot !== 'object') continue;
if (!hasModelIdentity(slot)) continue;
config[sectionKey] = mergeModelSlot(config[sectionKey] || {}, slot, stripApiKey, slotKey);
}
return config;
}
// ── Alpine Store ──
const API_BASE = "/plugins/_model_config";
@ -87,8 +180,9 @@ export const store = createStore("modelConfig", {
_normalizePresets(rawPresets) {
return (rawPresets || []).map(p => ({
name: p.name || '',
chat: { provider: '', name: '', api_key: '', api_base: '', ctx_length: 128000, ctx_history: 0.7, vision: true, rl_requests: 0, rl_input: 0, rl_output: 0, kwargs: {}, _kwargs_text: kwargsToText(p.chat?.kwargs), ...(p.chat || {}) },
utility: { provider: '', name: '', api_key: '', api_base: '', ctx_length: 128000, ctx_input: 0.7, rl_requests: 0, rl_input: 0, rl_output: 0, kwargs: {}, _kwargs_text: kwargsToText(p.utility?.kwargs), ...(p.utility || {}) },
chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.chat?.kwargs), ...(p.chat || {}) },
utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.utility?.kwargs), ...(p.utility || {}) },
embedding: p.embedding ? { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.embedding?.kwargs), ...(p.embedding || {}) } : undefined,
}));
},
@ -164,10 +258,14 @@ export const store = createStore("modelConfig", {
const c = { name: p.name };
for (const slot of ['chat', 'utility']) {
if (p[slot]) {
const { _kwargs_text, api_key, ...rest } = p[slot];
c[slot] = rest;
const rest = cleanPresetSlot(p[slot], true, slot);
if (hasModelIdentity(rest)) c[slot] = rest;
}
}
if (p.embedding) {
const embedding = cleanPresetSlot(p.embedding, true, 'embedding');
if (hasModelIdentity(embedding)) c.embedding = embedding;
}
return c;
});
try {

View file

@ -156,6 +156,18 @@ def test_project_presets_are_separate_and_resolve_by_scope(monkeypatch, tmp_path
)
def test_bundled_utility_presets_inherit_advanced_settings():
import yaml
presets_path = PROJECT_ROOT / "plugins" / "_model_config" / "default_presets.yaml"
presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
for preset in presets:
utility = preset.get("utility") or {}
assert "ctx_length" not in utility
assert "ctx_input" not in utility
@pytest.mark.asyncio
async def test_model_presets_api_returns_global_or_combined_by_project(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
@ -235,6 +247,194 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch,
assert "_model_config" not in project_json
def test_preset_application_deep_merges_model_slots(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
base_config = {
"allow_chat_override": True,
"chat_model": {
"provider": "openrouter",
"name": "configured-chat",
"ctx_length": 200000,
"ctx_history": 0.5,
"kwargs": {"temperature": 0.2, "routing": {"order": ["a", "b"]}},
},
"utility_model": {
"provider": "openrouter",
"name": "configured-utility",
"ctx_length": 200000,
"ctx_input": 0.4,
"kwargs": {"temperature": 0.1, "routing": {"order": ["fast"]}},
},
"embedding_model": {
"provider": "huggingface",
"name": "configured-embedding",
"kwargs": {"device": "cpu", "batch_size": 16},
},
}
preset = {
"name": "Research",
"chat": {
"provider": "anthropic",
"name": "claude-research",
"kwargs": {"routing": {"priority": "quality"}},
},
"utility": {
"provider": "openrouter",
"name": "utility-research",
"kwargs": {"routing": {"timeout": 30}},
},
"embedding": {
"provider": "openai",
"name": "text-embedding-3-large",
},
}
config = model_config.build_config_from_preset(preset, base_config)
assert config["chat_model"]["name"] == "claude-research"
assert config["chat_model"]["ctx_length"] == 200000
assert config["chat_model"]["kwargs"] == {
"temperature": 0.2,
"routing": {"order": ["a", "b"], "priority": "quality"},
}
assert config["utility_model"]["name"] == "utility-research"
assert config["utility_model"]["ctx_length"] == 200000
assert config["utility_model"]["ctx_input"] == 0.4
assert config["utility_model"]["kwargs"] == {
"temperature": 0.1,
"routing": {"order": ["fast"], "timeout": 30},
}
assert config["embedding_model"]["name"] == "text-embedding-3-large"
assert config["embedding_model"]["kwargs"] == {"device": "cpu", "batch_size": 16}
def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
base_config = {
"chat_model": {"provider": "openrouter", "name": "configured-chat"},
"utility_model": {
"provider": "openrouter",
"name": "configured-utility",
"ctx_length": 200000,
},
"embedding_model": {
"provider": "huggingface",
"name": "configured-embedding",
},
}
preset = {
"name": "Chat Only",
"chat": {"provider": "anthropic", "name": "claude-research"},
"utility": {"ctx_length": 128000},
}
config = model_config.build_config_from_preset(preset, base_config)
assert config["chat_model"]["name"] == "claude-research"
assert config["utility_model"] == base_config["utility_model"]
assert config["embedding_model"] == base_config["embedding_model"]
def test_legacy_utility_preset_defaults_do_not_override_tuned_config(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
base_config = {
"utility_model": {
"provider": "openrouter",
"name": "configured-utility",
"api_base": "https://custom.example/v1",
"ctx_length": 200000,
"ctx_input": 0.4,
"rl_requests": 12,
"rl_input": 34000,
"rl_output": 56000,
"kwargs": {"temperature": 0.1},
},
}
preset = {
"name": "Legacy Saved Preset",
"utility": {
"provider": "openrouter",
"name": "preset-utility",
"api_key": "",
"api_base": "",
"ctx_length": 128000,
"ctx_input": 0.7,
"rl_requests": 0,
"rl_input": 0,
"rl_output": 0,
"kwargs": {},
},
}
config = model_config.build_config_from_preset(
preset,
base_config,
strip_api_key=False,
)
utility = config["utility_model"]
assert utility["name"] == "preset-utility"
assert utility["api_base"] == ""
assert "api_key" not in utility
assert utility["ctx_length"] == 200000
assert utility["ctx_input"] == 0.4
assert utility["rl_requests"] == 12
assert utility["rl_input"] == 34000
assert utility["rl_output"] == 56000
assert utility["kwargs"] == {"temperature": 0.1}
def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
base_config = {
"allow_chat_override": True,
"chat_model": {"provider": "openrouter", "name": "configured-chat"},
"utility_model": {
"provider": "openrouter",
"name": "configured-utility",
"ctx_length": 200000,
"ctx_input": 0.4,
},
}
preset = {
"name": "Fast",
"chat": {"provider": "openrouter", "name": "fast-chat"},
"utility": {"provider": "openrouter", "name": "fast-utility"},
}
class FakeContext:
def get_data(self, key):
return {"preset_name": "Fast"} if key == "chat_model_override" else None
class FakeAgent:
context = FakeContext()
monkeypatch.setattr(model_config, "get_config", lambda *args, **kwargs: base_config)
monkeypatch.setattr(
model_config,
"get_preset_by_name",
lambda name, **kwargs: preset if name == "Fast" else None,
)
utility = model_config.get_utility_model_config(FakeAgent())
assert utility["name"] == "fast-utility"
assert utility["ctx_length"] == 200000
assert utility["ctx_input"] == 0.4
def test_project_save_disambiguates_same_name_project_preset(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)

View file

@ -5,7 +5,7 @@ import * as notifications from "/components/notifications/notification-store.js"
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
import { store as skillsImportStore } from "/components/settings/skills/skills-import-store.js";
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
import { store as modelConfigStore, configFromPreset } from "/plugins/_model_config/webui/model-config-store.js";
import * as shortcuts from "/js/shortcuts.js";
import { showConfirmDialog } from "/js/confirmDialog.js";
@ -546,12 +546,7 @@ const model = {
},
_configFromPreset(preset, baseConfig) {
const config = JSON.parse(JSON.stringify(baseConfig || {}));
if (preset.chat) config.chat_model = this._cleanModelSlot(preset.chat, true);
if (preset.utility?.provider || preset.utility?.name) {
config.utility_model = this._cleanModelSlot(preset.utility, true);
}
return config;
return configFromPreset(preset, baseConfig || {}, true);
},
_cleanModelSlot(slot, stripApiKey = true) {
@ -568,6 +563,7 @@ const model = {
name,
chat: this._cleanModelSlot(config?.chat_model || {}, true),
utility: this._cleanModelSlot(config?.utility_model || {}, true),
embedding: this._cleanModelSlot(config?.embedding_model || {}, true),
};
},