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.

Alessandro committed May 18, 2026 at 02:45 UTC e0337410e7f9a72cdee9e83dc15eb25b5e5cd5e7
9 files changed +487 -50
docs/guides/model-presets.md
+5
@@ -39,6 +39,11 @@ Think of a preset as a label on a model setup.
39 | **Main model** | The model that does the main conversation and reasoning. |
40 | **Utility model** | A smaller helper model for lighter internal tasks. |
41
42 +Presets can be partial. If a preset does not set a utility model, Agent Zero
43 +uses your configured Utility Model. If a preset changes the utility model but
44 +does not set advanced fields, your configured context window, rate limits, and
45 +other advanced values stay in place.
46 +
47 ## Add A Preset
48
49 Click **Add Preset**, give it a name, choose models, then click **Save Presets**.
plugins/_browser/helpers/config.py
+10 -1
@@ -223,7 +223,16 @@ def resolve_browser_model_selection(
223 if preset_name:
224 preset = model_config.get_preset_by_name(preset_name)
225 if isinstance(preset, dict):
226 - chat_cfg = preset.get("chat", {})
226 + if hasattr(model_config, "build_config_from_preset"):
227 + preset_config = model_config.build_config_from_preset(
228 + preset,
229 + model_config.get_config(agent) if hasattr(model_config, "get_config") else {},
230 + strip_api_key=False,
231 + slots=("chat",),
232 + )
233 + chat_cfg = preset_config.get("chat_model", {})
234 + else:
235 + chat_cfg = preset.get("chat", {})
236 if isinstance(chat_cfg, dict) and (
237 str(chat_cfg.get("provider", "") or "").strip()
238 or str(chat_cfg.get("name", "") or "").strip()
plugins/_model_config/README.md
+1 -4
@@ -75,12 +75,9 @@ The project preset file uses the same plain YAML list schema as global presets.
75 utility:
76 provider: openrouter
77 name: openai/gpt-5.4-mini
78 - api_base: ""
79 - ctx_length: 128000
80 - ctx_input: 0.7
78 ```
79
83 -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.
80 +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`.
81
82 ## Plugin Metadata
83
plugins/_model_config/default_presets.yaml
-12
@@ -10,10 +10,6 @@
10 utility:
11 provider: "openrouter"
12 name: "openai/gpt-5.4-mini"
13 - api_key: ""
14 - api_base: ""
15 - ctx_length: 128000
16 - ctx_input: 0.7
13 - name: "Balance"
14 chat:
15 provider: "openrouter"
@@ -26,10 +22,6 @@
22 utility:
23 provider: "openrouter"
24 name: "google/gemini-3.1-flash-lite-preview"
29 - api_key: ""
30 - api_base: ""
31 - ctx_length: 128000
32 - ctx_input: 0.7
25 - name: "Cost Efficient"
26 chat:
27 provider: "openrouter"
@@ -42,7 +34,3 @@
34 utility:
35 provider: "openrouter"
36 name: "openai/gpt-5.4-nano"
45 - api_key: ""
46 - api_base: ""
47 - ctx_length: 128000
48 - ctx_input: 0.7
plugins/_model_config/helpers/model_config.py
+164 -20
@@ -11,6 +11,26 @@ DEFAULT_PRESETS_FILE = "default_presets.yaml"
11 PROVIDER_METADATA_FILE = "provider_metadata.yaml"
12 PRESET_SCOPE_GLOBAL = "global"
13 PRESET_SCOPE_PROJECT = "project"
14 +PRESET_SLOT_CONFIG_SECTIONS = {
15 + "chat": "chat_model",
16 + "utility": "utility_model",
17 + "embedding": "embedding_model",
18 +}
19 +IMPLICIT_PRESET_SLOT_DEFAULTS = {
20 + "utility": {
21 + "ctx_length": 128000,
22 + "ctx_input": 0.7,
23 + "rl_requests": 0,
24 + "rl_input": 0,
25 + "rl_output": 0,
26 + "kwargs": {},
27 + },
28 + "embedding": {
29 + "rl_requests": 0,
30 + "rl_input": 0,
31 + "kwargs": {},
32 + },
33 +}
34 LOCAL_PROVIDERS = {"ollama", "lm_studio"}
35 LOCAL_EMBEDDING = {"huggingface"}
36 _PROVIDER_METADATA_CACHE: dict | None = None
@@ -96,14 +116,33 @@ def _strip_ui_fields(value: dict, *, strip_api_key: bool) -> dict:
116 return cleaned
117
118
119 +def _preset_default_values_equal(value, default) -> bool:
120 + if isinstance(default, float):
121 + try:
122 + return float(value) == default
123 + except (TypeError, ValueError):
124 + return False
125 + return value == default
126 +
127 +
128 +def _strip_implicit_preset_defaults(slot: str, slot_config: dict) -> dict:
129 + cleaned = deepcopy(slot_config)
130 + defaults = IMPLICIT_PRESET_SLOT_DEFAULTS.get(slot, {})
131 + for key, default in defaults.items():
132 + if key in cleaned and _preset_default_values_equal(cleaned[key], default):
133 + cleaned.pop(key, None)
134 + return cleaned
135 +
136 +
137 def _clean_preset_for_file(preset: dict) -> dict:
138 cleaned = {
139 "name": str(preset.get("name", "") or ""),
140 }
103 - for slot in ("chat", "utility"):
141 + for slot in PRESET_SLOT_CONFIG_SECTIONS:
142 slot_config = preset.get(slot)
143 if isinstance(slot_config, dict):
106 - cleaned[slot] = _strip_ui_fields(slot_config, strip_api_key=False)
144 + slot_clean = _strip_ui_fields(slot_config, strip_api_key=False)
145 + cleaned[slot] = _strip_implicit_preset_defaults(slot, slot_clean)
146 return cleaned
147
148
@@ -230,17 +269,115 @@ def get_preset_by_name(
269 return resolve_preset(name, scope=scope, project_name=project_name)
270
271
233 -def build_config_from_preset(preset: dict, base_config: dict) -> dict:
234 - """Copy chat/utility settings from a preset into a standalone model config."""
235 - config = normalize_config_for_save(base_config)
272 +def _deep_merge_dict(base: dict, override: dict) -> dict:
273 + """Recursively overlay override onto base without mutating either input."""
274 + result = deepcopy(base) if isinstance(base, dict) else {}
275 + for key, value in override.items():
276 + if (
277 + isinstance(value, dict)
278 + and isinstance(result.get(key), dict)
279 + ):
280 + result[key] = _deep_merge_dict(result[key], value)
281 + else:
282 + result[key] = deepcopy(value)
283 + return result
284 +
285 +
286 +def _slot_has_identity(slot_config: dict) -> bool:
287 + return bool(slot_config.get("provider") or slot_config.get("name"))
288 +
289
237 - chat = preset.get("chat") if isinstance(preset, dict) else None
238 - if isinstance(chat, dict):
239 - config["chat_model"] = _strip_ui_fields(chat, strip_api_key=True)
290 +def _get_preset_slot_config(preset: dict, slot: str) -> dict | None:
291 + """Return the preset payload for a slot.
292 +
293 + Legacy raw overrides store the main/chat model directly at the top level,
294 + while named presets store it under the "chat" key.
295 + """
296 + if not isinstance(preset, dict):
297 + return None
298 +
299 + slot_config = preset.get(slot)
300 + if isinstance(slot_config, dict):
301 + return slot_config
302 +
303 + if slot == "chat" and not any(key in preset for key in PRESET_SLOT_CONFIG_SECTIONS):
304 + if _slot_has_identity(preset):
305 + return preset
306 +
307 + return None
308
241 - utility = preset.get("utility") if isinstance(preset, dict) else None
242 - if isinstance(utility, dict) and (utility.get("provider") or utility.get("name")):
243 - config["utility_model"] = _strip_ui_fields(utility, strip_api_key=True)
309 +
310 +def _should_apply_preset_slot(slot: str, slot_config: dict | None) -> bool:
311 + if not isinstance(slot_config, dict):
312 + return False
313 +
314 + cleaned = _strip_implicit_preset_defaults(
315 + slot,
316 + _strip_ui_fields(slot_config, strip_api_key=False),
317 + )
318 + meaningful = {
319 + key: value
320 + for key, value in cleaned.items()
321 + if key != "api_key"
322 + }
323 + if not meaningful:
324 + return False
325 +
326 + # Slots inherit the configured model unless the preset declares a model
327 + # identity for that slot. This keeps empty UI placeholders from accidentally
328 + # overriding context/rate-limit settings.
329 + return _slot_has_identity(cleaned)
330 +
331 +
332 +def _merge_model_slot(
333 + slot: str,
334 + base_slot: dict,
335 + preset_slot: dict,
336 + *,
337 + strip_api_key: bool,
338 +) -> dict:
339 + cleaned = _strip_implicit_preset_defaults(
340 + slot,
341 + _strip_ui_fields(preset_slot, strip_api_key=strip_api_key),
342 + )
343 + if not strip_api_key and not str(cleaned.get("api_key") or "").strip():
344 + cleaned.pop("api_key", None)
345 + return _deep_merge_dict(base_slot if isinstance(base_slot, dict) else {}, cleaned)
346 +
347 +
348 +def build_config_from_preset(
349 + preset: dict,
350 + base_config: dict,
351 + *,
352 + strip_api_key: bool = True,
353 + slots: tuple[str, ...] | None = None,
354 +) -> dict:
355 + """Overlay preset settings onto a standalone model config.
356 +
357 + Presets are intentionally partial: omitted fields inherit from the current
358 + config, so selecting a preset does not reset tuned values such as context
359 + windows, rate limits, or nested kwargs unless the preset explicitly defines
360 + them.
361 + """
362 + config = (
363 + normalize_config_for_save(base_config)
364 + if strip_api_key
365 + else deepcopy(base_config or {})
366 + )
367 +
368 + for slot in slots or tuple(PRESET_SLOT_CONFIG_SECTIONS):
369 + section = PRESET_SLOT_CONFIG_SECTIONS.get(slot)
370 + if not section:
371 + continue
372 + slot_config = _get_preset_slot_config(preset, slot)
373 + if not _should_apply_preset_slot(slot, slot_config):
374 + continue
375 + config[section] = _merge_model_slot(
376 + slot,
377 + config.get(section, {}),
378 + slot_config,
379 + strip_api_key=strip_api_key,
380 + )
381
382 return config
383
@@ -269,24 +406,31 @@ def _resolve_override(agent) -> dict | None:
406
407 def get_chat_model_config(agent=None) -> dict:
408 """Get chat model config, with per-chat override if active."""
409 + cfg = get_config(agent)
410 override = _resolve_override(agent)
411 if override:
274 - # Preset has a nested 'chat' key; raw override is flat
275 - chat_cfg = override.get("chat", override)
276 - if chat_cfg.get("provider") or chat_cfg.get("name"):
277 - return chat_cfg
278 - cfg = get_config(agent)
412 + config = build_config_from_preset(
413 + override,
414 + cfg,
415 + strip_api_key=False,
416 + slots=("chat",),
417 + )
418 + return config.get("chat_model", {})
419 return cfg.get("chat_model", {})
420
421
422 def get_utility_model_config(agent=None) -> dict:
423 """Get utility model config, with per-chat override if active."""
424 + cfg = get_config(agent)
425 override = _resolve_override(agent)
426 if override:
286 - util_cfg = override.get("utility", {})
287 - if util_cfg.get("provider") or util_cfg.get("name"):
288 - return util_cfg
289 - cfg = get_config(agent)
427 + config = build_config_from_preset(
428 + override,
429 + cfg,
430 + strip_api_key=False,
431 + slots=("utility",),
432 + )
433 + return config.get("utility_model", {})
434 return cfg.get("utility_model", {})
435
436
plugins/_model_config/webui/main.html
+2 -2
@@ -65,8 +65,8 @@
65 @click="
66 presets = [...presets, {
67 name: 'Preset ' + (presets.length + 1),
68 - 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: '' },
69 - 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: '' }
68 + chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
69 + utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' }
70 }]">
71 <span class="material-symbols-outlined">add</span>
72 <span>Add Preset</span>
plugins/_model_config/webui/model-config-store.js
+102 -4
@@ -46,6 +46,99 @@ export function textToHeaders(text) {
46 return d;
47 }
48
49 +function clonePlain(value) {
50 + if (value === undefined) return undefined;
51 + return JSON.parse(JSON.stringify(value));
52 +}
53 +
54 +function isBlankPresetValue(value) {
55 + if (value === undefined || value === null || value === '') return true;
56 + if (Array.isArray(value)) return value.length === 0;
57 + if (typeof value === 'object') return Object.keys(value).length === 0;
58 + return false;
59 +}
60 +
61 +const IMPLICIT_PRESET_SLOT_DEFAULTS = {
62 + utility: {
63 + ctx_length: 128000,
64 + ctx_input: 0.7,
65 + rl_requests: 0,
66 + rl_input: 0,
67 + rl_output: 0,
68 + kwargs: {},
69 + },
70 + embedding: {
71 + rl_requests: 0,
72 + rl_input: 0,
73 + kwargs: {},
74 + },
75 +};
76 +
77 +function presetDefaultValuesEqual(value, defaultValue) {
78 + if (typeof defaultValue === 'number') return Number(value) === defaultValue;
79 + return JSON.stringify(value) === JSON.stringify(defaultValue);
80 +}
81 +
82 +function cleanPresetSlot(slot, stripApiKey = true, slotKey = '') {
83 + const clean = {};
84 + const implicitDefaults = IMPLICIT_PRESET_SLOT_DEFAULTS[slotKey] || {};
85 + for (const [key, value] of Object.entries(slot || {})) {
86 + if (key.startsWith('_')) continue;
87 + if (stripApiKey && key === 'api_key') continue;
88 + if (key === 'api_base' && value === '') {
89 + clean[key] = value;
90 + continue;
91 + }
92 + if (key === 'kwargs' && isBlankPresetValue(value)) continue;
93 + if (isBlankPresetValue(value)) continue;
94 + if (key in implicitDefaults && presetDefaultValuesEqual(value, implicitDefaults[key])) continue;
95 + clean[key] = value;
96 + }
97 + return clean;
98 +}
99 +
100 +function hasModelIdentity(slot) {
101 + return !!(slot?.provider || slot?.name);
102 +}
103 +
104 +export function mergeModelSlot(baseSlot, presetSlot, stripApiKey = true, slotKey = '') {
105 + const result = clonePlain(baseSlot || {});
106 + const clean = cleanPresetSlot(presetSlot, stripApiKey, slotKey);
107 + for (const [key, value] of Object.entries(clean)) {
108 + if (
109 + value &&
110 + typeof value === 'object' &&
111 + !Array.isArray(value) &&
112 + result[key] &&
113 + typeof result[key] === 'object' &&
114 + !Array.isArray(result[key])
115 + ) {
116 + result[key] = mergeModelSlot(result[key], value, false);
117 + } else {
118 + result[key] = clonePlain(value);
119 + }
120 + }
121 + return result;
122 +}
123 +
124 +export function configFromPreset(preset, baseConfig, stripApiKey = true) {
125 + const config = clonePlain(baseConfig || {});
126 + const slots = [
127 + ['chat', 'chat_model'],
128 + ['utility', 'utility_model'],
129 + ['embedding', 'embedding_model'],
130 + ];
131 +
132 + for (const [slotKey, sectionKey] of slots) {
133 + const slot = preset?.[slotKey];
134 + if (!slot || typeof slot !== 'object') continue;
135 + if (!hasModelIdentity(slot)) continue;
136 + config[sectionKey] = mergeModelSlot(config[sectionKey] || {}, slot, stripApiKey, slotKey);
137 + }
138 +
139 + return config;
140 +}
141 +
142 // ── Alpine Store ──
143
144 const API_BASE = "/plugins/_model_config";
@@ -87,8 +180,9 @@ export const store = createStore("modelConfig", {
180 _normalizePresets(rawPresets) {
181 return (rawPresets || []).map(p => ({
182 name: p.name || '',
90 - 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 || {}) },
91 - 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 || {}) },
183 + chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.chat?.kwargs), ...(p.chat || {}) },
184 + utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.utility?.kwargs), ...(p.utility || {}) },
185 + embedding: p.embedding ? { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.embedding?.kwargs), ...(p.embedding || {}) } : undefined,
186 }));
187 },
188
@@ -164,10 +258,14 @@ export const store = createStore("modelConfig", {
258 const c = { name: p.name };
259 for (const slot of ['chat', 'utility']) {
260 if (p[slot]) {
167 - const { _kwargs_text, api_key, ...rest } = p[slot];
168 - c[slot] = rest;
261 + const rest = cleanPresetSlot(p[slot], true, slot);
262 + if (hasModelIdentity(rest)) c[slot] = rest;
263 }
264 }
265 + if (p.embedding) {
266 + const embedding = cleanPresetSlot(p.embedding, true, 'embedding');
267 + if (hasModelIdentity(embedding)) c.embedding = embedding;
268 + }
269 return c;
270 });
271 try {
tests/test_model_config_project_presets.py
+200
@@ -156,6 +156,18 @@ def test_project_presets_are_separate_and_resolve_by_scope(monkeypatch, tmp_path
156 )
157
158
159 +def test_bundled_utility_presets_inherit_advanced_settings():
160 + import yaml
161 +
162 + presets_path = PROJECT_ROOT / "plugins" / "_model_config" / "default_presets.yaml"
163 + presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
164 +
165 + for preset in presets:
166 + utility = preset.get("utility") or {}
167 + assert "ctx_length" not in utility
168 + assert "ctx_input" not in utility
169 +
170 +
171 @pytest.mark.asyncio
172 async def test_model_presets_api_returns_global_or_combined_by_project(monkeypatch, tmp_path):
173 _prepare_a0_tree(monkeypatch, tmp_path)
@@ -235,6 +247,194 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch,
247 assert "_model_config" not in project_json
248
249
250 +def test_preset_application_deep_merges_model_slots(monkeypatch, tmp_path):
251 + _prepare_a0_tree(monkeypatch, tmp_path)
252 +
253 + from plugins._model_config.helpers import model_config
254 +
255 + base_config = {
256 + "allow_chat_override": True,
257 + "chat_model": {
258 + "provider": "openrouter",
259 + "name": "configured-chat",
260 + "ctx_length": 200000,
261 + "ctx_history": 0.5,
262 + "kwargs": {"temperature": 0.2, "routing": {"order": ["a", "b"]}},
263 + },
264 + "utility_model": {
265 + "provider": "openrouter",
266 + "name": "configured-utility",
267 + "ctx_length": 200000,
268 + "ctx_input": 0.4,
269 + "kwargs": {"temperature": 0.1, "routing": {"order": ["fast"]}},
270 + },
271 + "embedding_model": {
272 + "provider": "huggingface",
273 + "name": "configured-embedding",
274 + "kwargs": {"device": "cpu", "batch_size": 16},
275 + },
276 + }
277 + preset = {
278 + "name": "Research",
279 + "chat": {
280 + "provider": "anthropic",
281 + "name": "claude-research",
282 + "kwargs": {"routing": {"priority": "quality"}},
283 + },
284 + "utility": {
285 + "provider": "openrouter",
286 + "name": "utility-research",
287 + "kwargs": {"routing": {"timeout": 30}},
288 + },
289 + "embedding": {
290 + "provider": "openai",
291 + "name": "text-embedding-3-large",
292 + },
293 + }
294 +
295 + config = model_config.build_config_from_preset(preset, base_config)
296 +
297 + assert config["chat_model"]["name"] == "claude-research"
298 + assert config["chat_model"]["ctx_length"] == 200000
299 + assert config["chat_model"]["kwargs"] == {
300 + "temperature": 0.2,
301 + "routing": {"order": ["a", "b"], "priority": "quality"},
302 + }
303 + assert config["utility_model"]["name"] == "utility-research"
304 + assert config["utility_model"]["ctx_length"] == 200000
305 + assert config["utility_model"]["ctx_input"] == 0.4
306 + assert config["utility_model"]["kwargs"] == {
307 + "temperature": 0.1,
308 + "routing": {"order": ["fast"], "timeout": 30},
309 + }
310 + assert config["embedding_model"]["name"] == "text-embedding-3-large"
311 + assert config["embedding_model"]["kwargs"] == {"device": "cpu", "batch_size": 16}
312 +
313 +
314 +def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
315 + _prepare_a0_tree(monkeypatch, tmp_path)
316 +
317 + from plugins._model_config.helpers import model_config
318 +
319 + base_config = {
320 + "chat_model": {"provider": "openrouter", "name": "configured-chat"},
321 + "utility_model": {
322 + "provider": "openrouter",
323 + "name": "configured-utility",
324 + "ctx_length": 200000,
325 + },
326 + "embedding_model": {
327 + "provider": "huggingface",
328 + "name": "configured-embedding",
329 + },
330 + }
331 + preset = {
332 + "name": "Chat Only",
333 + "chat": {"provider": "anthropic", "name": "claude-research"},
334 + "utility": {"ctx_length": 128000},
335 + }
336 +
337 + config = model_config.build_config_from_preset(preset, base_config)
338 +
339 + assert config["chat_model"]["name"] == "claude-research"
340 + assert config["utility_model"] == base_config["utility_model"]
341 + assert config["embedding_model"] == base_config["embedding_model"]
342 +
343 +
344 +def test_legacy_utility_preset_defaults_do_not_override_tuned_config(monkeypatch, tmp_path):
345 + _prepare_a0_tree(monkeypatch, tmp_path)
346 +
347 + from plugins._model_config.helpers import model_config
348 +
349 + base_config = {
350 + "utility_model": {
351 + "provider": "openrouter",
352 + "name": "configured-utility",
353 + "api_base": "https://custom.example/v1",
354 + "ctx_length": 200000,
355 + "ctx_input": 0.4,
356 + "rl_requests": 12,
357 + "rl_input": 34000,
358 + "rl_output": 56000,
359 + "kwargs": {"temperature": 0.1},
360 + },
361 + }
362 + preset = {
363 + "name": "Legacy Saved Preset",
364 + "utility": {
365 + "provider": "openrouter",
366 + "name": "preset-utility",
367 + "api_key": "",
368 + "api_base": "",
369 + "ctx_length": 128000,
370 + "ctx_input": 0.7,
371 + "rl_requests": 0,
372 + "rl_input": 0,
373 + "rl_output": 0,
374 + "kwargs": {},
375 + },
376 + }
377 +
378 + config = model_config.build_config_from_preset(
379 + preset,
380 + base_config,
381 + strip_api_key=False,
382 + )
383 +
384 + utility = config["utility_model"]
385 + assert utility["name"] == "preset-utility"
386 + assert utility["api_base"] == ""
387 + assert "api_key" not in utility
388 + assert utility["ctx_length"] == 200000
389 + assert utility["ctx_input"] == 0.4
390 + assert utility["rl_requests"] == 12
391 + assert utility["rl_input"] == 34000
392 + assert utility["rl_output"] == 56000
393 + assert utility["kwargs"] == {"temperature": 0.1}
394 +
395 +
396 +def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_path):
397 + _prepare_a0_tree(monkeypatch, tmp_path)
398 +
399 + from plugins._model_config.helpers import model_config
400 +
401 + base_config = {
402 + "allow_chat_override": True,
403 + "chat_model": {"provider": "openrouter", "name": "configured-chat"},
404 + "utility_model": {
405 + "provider": "openrouter",
406 + "name": "configured-utility",
407 + "ctx_length": 200000,
408 + "ctx_input": 0.4,
409 + },
410 + }
411 + preset = {
412 + "name": "Fast",
413 + "chat": {"provider": "openrouter", "name": "fast-chat"},
414 + "utility": {"provider": "openrouter", "name": "fast-utility"},
415 + }
416 +
417 + class FakeContext:
418 + def get_data(self, key):
419 + return {"preset_name": "Fast"} if key == "chat_model_override" else None
420 +
421 + class FakeAgent:
422 + context = FakeContext()
423 +
424 + monkeypatch.setattr(model_config, "get_config", lambda *args, **kwargs: base_config)
425 + monkeypatch.setattr(
426 + model_config,
427 + "get_preset_by_name",
428 + lambda name, **kwargs: preset if name == "Fast" else None,
429 + )
430 +
431 + utility = model_config.get_utility_model_config(FakeAgent())
432 +
433 + assert utility["name"] == "fast-utility"
434 + assert utility["ctx_length"] == 200000
435 + assert utility["ctx_input"] == 0.4
436 +
437 +
438 def test_project_save_disambiguates_same_name_project_preset(monkeypatch, tmp_path):
439 _prepare_a0_tree(monkeypatch, tmp_path)
440
webui/components/projects/projects-store.js
+3 -7
@@ -5,7 +5,7 @@ import * as notifications from "/components/notifications/notification-store.js"
5 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6 import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
7 import { store as skillsImportStore } from "/components/settings/skills/skills-import-store.js";
8 -import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
8 +import { store as modelConfigStore, configFromPreset } from "/plugins/_model_config/webui/model-config-store.js";
9 import * as shortcuts from "/js/shortcuts.js";
10 import { showConfirmDialog } from "/js/confirmDialog.js";
11
@@ -546,12 +546,7 @@ const model = {
546 },
547
548 _configFromPreset(preset, baseConfig) {
549 - const config = JSON.parse(JSON.stringify(baseConfig || {}));
550 - if (preset.chat) config.chat_model = this._cleanModelSlot(preset.chat, true);
551 - if (preset.utility?.provider || preset.utility?.name) {
552 - config.utility_model = this._cleanModelSlot(preset.utility, true);
553 - }
554 - return config;
549 + return configFromPreset(preset, baseConfig || {}, true);
550 },
551
552 _cleanModelSlot(slot, stripApiKey = true) {
@@ -568,6 +563,7 @@ const model = {
563 name,
564 chat: this._cleanModelSlot(config?.chat_model || {}, true),
565 utility: this._cleanModelSlot(config?.utility_model || {}, true),
566 + embedding: this._cleanModelSlot(config?.embedding_model || {}, true),
567 };
568 },
569