diff --git a/docs/guides/model-presets.md b/docs/guides/model-presets.md
index a809318f6..18f3bc0f2 100644
--- a/docs/guides/model-presets.md
+++ b/docs/guides/model-presets.md
@@ -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**.
diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py
index bf19f08c7..f39e52e51 100644
--- a/plugins/_browser/helpers/config.py
+++ b/plugins/_browser/helpers/config.py
@@ -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()
diff --git a/plugins/_model_config/README.md b/plugins/_model_config/README.md
index d95fa653d..b69837f37 100644
--- a/plugins/_model_config/README.md
+++ b/plugins/_model_config/README.md
@@ -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
diff --git a/plugins/_model_config/default_presets.yaml b/plugins/_model_config/default_presets.yaml
index 1d20b6232..be3dcabfd 100644
--- a/plugins/_model_config/default_presets.yaml
+++ b/plugins/_model_config/default_presets.yaml
@@ -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
diff --git a/plugins/_model_config/helpers/model_config.py b/plugins/_model_config/helpers/model_config.py
index 30a7c731f..e01430304 100644
--- a/plugins/_model_config/helpers/model_config.py
+++ b/plugins/_model_config/helpers/model_config.py
@@ -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", {})
diff --git a/plugins/_model_config/webui/main.html b/plugins/_model_config/webui/main.html
index b1b988b53..a7ba13605 100644
--- a/plugins/_model_config/webui/main.html
+++ b/plugins/_model_config/webui/main.html
@@ -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: '' }
}]">
add
Add Preset
diff --git a/plugins/_model_config/webui/model-config-store.js b/plugins/_model_config/webui/model-config-store.js
index 268952a34..183a4b781 100644
--- a/plugins/_model_config/webui/model-config-store.js
+++ b/plugins/_model_config/webui/model-config-store.js
@@ -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 {
diff --git a/tests/test_model_config_project_presets.py b/tests/test_model_config_project_presets.py
index a0d1d1b81..18ce5128c 100644
--- a/tests/test_model_config_project_presets.py
+++ b/tests/test_model_config_project_presets.py
@@ -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)
diff --git a/webui/components/projects/projects-store.js b/webui/components/projects/projects-store.js
index 69818d152..910200741 100644
--- a/webui/components/projects/projects-store.js
+++ b/webui/components/projects/projects-store.js
@@ -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),
};
},