Clear stale preset kwargs on model switches
Treat model-slot kwargs as provider-specific preset fields: applying a preset now replaces explicit kwargs and clears inherited kwargs when the preset omits them, while preserving durable tuning like context windows and rate limits. Mirror the behavior in the WebUI preset merge helper and add regressions for Codex-like presets so unsupported parameters such as temperature cannot leak into Responses API providers.
Alessandro committed
Jun 11, 2026 at 19:13 UTC
d6e8f06ec002d70b3e217a0255d01893fb4976da
4 files changed
+79
-15
plugins/_model_config/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Preserve global, project, agent, and chat override resolution order.
17
- Keep provider metadata and API-key checks safe around secrets.
18
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
19
+- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
20
21
## Work Guidance
22
plugins/_model_config/helpers/model_config.py
+16
-3
@@ -16,6 +16,7 @@ PRESET_SLOT_CONFIG_SECTIONS = {
16
"utility": "utility_model",
17
"embedding": "embedding_model",
18
}
19
+MODEL_SLOT_PRESET_REPLACE_FIELDS = {"kwargs"}
20
IMPLICIT_PRESET_SLOT_DEFAULTS = {
21
"utility": {
22
"ctx_length": 128000,
@@ -283,6 +284,17 @@ def _deep_merge_dict(base: dict, override: dict) -> dict:
284
return result
285
286
287
+def _replace_preset_model_slot_fields(base: dict, override: dict, result: dict) -> dict:
288
+ """Clear or replace provider-specific fields that must not leak across presets."""
289
+ for key in MODEL_SLOT_PRESET_REPLACE_FIELDS:
290
+ if key in override:
291
+ value = override.get(key)
292
+ result[key] = deepcopy(value) if isinstance(value, dict) else {}
293
+ elif key in base:
294
+ result[key] = {}
295
+ return result
296
+
297
+
298
def _slot_has_identity(slot_config: dict) -> bool:
299
return bool(slot_config.get("provider") or slot_config.get("name"))
300
@@ -342,7 +354,8 @@ def _merge_model_slot(
354
)
355
if not strip_api_key and not str(cleaned.get("api_key") or "").strip():
356
cleaned.pop("api_key", None)
345
- return _deep_merge_dict(base_slot if isinstance(base_slot, dict) else {}, cleaned)
357
+ base = base_slot if isinstance(base_slot, dict) else {}
358
+ return _replace_preset_model_slot_fields(base, cleaned, _deep_merge_dict(base, cleaned))
359
360
361
def build_config_from_preset(
@@ -356,8 +369,8 @@ def build_config_from_preset(
369
370
Presets are intentionally partial: omitted fields inherit from the current
371
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.
372
+ windows or rate limits. Provider-specific kwargs are replaced when present
373
+ and cleared when omitted so stale params do not leak between providers.
374
"""
375
config = (
376
normalize_config_for_save(base_config)
plugins/_model_config/webui/model-config-store.js
+9
@@ -73,6 +73,7 @@ const IMPLICIT_PRESET_SLOT_DEFAULTS = {
73
kwargs: {},
74
},
75
};
76
+const PRESET_REPLACE_FIELDS = new Set(['kwargs']);
77
78
function presetDefaultValuesEqual(value, defaultValue) {
79
if (typeof defaultValue === 'number') return Number(value) === defaultValue;
@@ -118,6 +119,14 @@ export function mergeModelSlot(baseSlot, presetSlot, stripApiKey = true, slotKey
119
result[key] = clonePlain(value);
120
}
121
}
122
+ for (const key of PRESET_REPLACE_FIELDS) {
123
+ if (Object.prototype.hasOwnProperty.call(clean, key)) {
124
+ const value = clean[key];
125
+ result[key] = value && typeof value === 'object' && !Array.isArray(value) ? clonePlain(value) : {};
126
+ } else if (Object.prototype.hasOwnProperty.call(baseSlot || {}, key)) {
127
+ result[key] = {};
128
+ }
129
+ }
130
return result;
131
}
132
tests/test_model_config_project_presets.py
+53
-12
@@ -247,7 +247,7 @@ 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):
250
+def test_preset_application_preserves_tuning_but_replaces_kwargs(monkeypatch, tmp_path):
251
_prepare_a0_tree(monkeypatch, tmp_path)
252
253
from plugins._model_config.helpers import model_config
@@ -296,19 +296,57 @@ def test_preset_application_deep_merges_model_slots(monkeypatch, tmp_path):
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
- }
299
+ assert config["chat_model"]["kwargs"] == {"routing": {"priority": "quality"}}
300
assert config["utility_model"]["name"] == "utility-research"
301
assert config["utility_model"]["ctx_length"] == 200000
302
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
- }
303
+ assert config["utility_model"]["kwargs"] == {"routing": {"timeout": 30}}
304
assert config["embedding_model"]["name"] == "text-embedding-3-large"
311
- assert config["embedding_model"]["kwargs"] == {"device": "cpu", "batch_size": 16}
305
+ assert config["embedding_model"]["kwargs"] == {}
306
+
307
+
308
+def test_preset_application_clears_stale_kwargs_when_preset_omits_them(
309
+ monkeypatch,
310
+ tmp_path,
311
+):
312
+ _prepare_a0_tree(monkeypatch, tmp_path)
313
+
314
+ from plugins._model_config.helpers import model_config
315
+
316
+ base_config = {
317
+ "chat_model": {
318
+ "provider": "openrouter",
319
+ "name": "openai/gpt-5.4",
320
+ "ctx_length": 200000,
321
+ "kwargs": {"temperature": 0, "extra_headers": {"x-old": "true"}},
322
+ },
323
+ "utility_model": {
324
+ "provider": "openrouter",
325
+ "name": "openai/gpt-5.4-mini",
326
+ "ctx_length": 128000,
327
+ "kwargs": {"temperature": 0},
328
+ },
329
+ }
330
+ preset = {
331
+ "name": "Codex",
332
+ "chat": {
333
+ "provider": "codex_oauth",
334
+ "name": "gpt-5.1-codex",
335
+ },
336
+ "utility": {
337
+ "provider": "codex_oauth",
338
+ "name": "gpt-5.1-codex-mini",
339
+ },
340
+ }
341
+
342
+ config = model_config.build_config_from_preset(preset, base_config)
343
+
344
+ assert config["chat_model"]["name"] == "gpt-5.1-codex"
345
+ assert config["chat_model"]["ctx_length"] == 200000
346
+ assert config["chat_model"]["kwargs"] == {}
347
+ assert config["utility_model"]["name"] == "gpt-5.1-codex-mini"
348
+ assert config["utility_model"]["ctx_length"] == 128000
349
+ assert config["utility_model"]["kwargs"] == {}
350
351
352
def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
@@ -341,7 +379,10 @@ def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
379
assert config["embedding_model"] == base_config["embedding_model"]
380
381
344
-def test_legacy_utility_preset_defaults_do_not_override_tuned_config(monkeypatch, tmp_path):
382
+def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
383
+ monkeypatch,
384
+ tmp_path,
385
+):
386
_prepare_a0_tree(monkeypatch, tmp_path)
387
388
from plugins._model_config.helpers import model_config
@@ -390,7 +431,7 @@ def test_legacy_utility_preset_defaults_do_not_override_tuned_config(monkeypatch
431
assert utility["rl_requests"] == 12
432
assert utility["rl_input"] == 34000
433
assert utility["rl_output"] == 56000
393
- assert utility["kwargs"] == {"temperature": 0.1}
434
+ assert utility["kwargs"] == {}
435
436
437
def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_path):