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):