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