Add project-scoped LLM presets
Add LLM preset selection to project create/edit flows, backed by _model_config scoped project config. Support global, project, and combined preset APIs with explicit metadata while preserving plain YAML preset files. Copy selected preset chat/utility settings into project-scoped config, keep embedding settings from the effective config, and document/test the new project model config paths.
Alessandro committed
Apr 21, 2026 at 15:32 UTC
fe2310aa90000acb7b1e356aae41f78c83579b87
13 files changed
+1020
-38
docs/developer/extensions.md
+20
@@ -343,6 +343,7 @@ Each project directory contains a hidden `.a0proj` folder with project metadata
343
├── instructions/ # additional prompt/instruction files
344
├── knowledge/ # files to be imported into memory
345
├── memory/ # project-specific memory storage
346
+ ├── plugins/ # project-scoped plugin configuration
347
├── secrets.env # sensitive variables (secrets)
348
└── variables.env # non-sensitive variables
349
```
@@ -369,6 +370,25 @@ Each project manages its own configuration values via environment files in `.a0p
370
371
These files allow you to keep credentials and configuration tightly scoped to a single project.
372
373
+Plugin-owned project configuration is stored under `.a0proj/plugins/<plugin_name>/`. For example, `_model_config` stores project model settings and project-only presets here:
374
+
375
+```text
376
+/a0/usr/projects/{project_name}/.a0proj/plugins/_model_config/config.json
377
+/a0/usr/projects/{project_name}/.a0proj/plugins/_model_config/presets.yaml
378
+```
379
+
380
+The `_model_config/presets.yaml` file is a plain YAML list:
381
+
382
+```yaml
383
+- name: Research
384
+ chat:
385
+ provider: openrouter
386
+ name: anthropic/claude-sonnet-4.6
387
+ utility:
388
+ provider: openrouter
389
+ name: openai/gpt-5.4-mini
390
+```
391
+
392
### When to Use Projects
393
394
Projects are the recommended way to create specialized workflows in Agent Zero when you need to:
docs/guides/projects.md
+35
@@ -223,6 +223,41 @@ SMTP_PASSWORD=email_pwd_here
223
224
Projects can enable or disable specific subagents. This is configured via the UI and stored in `.a0proj/agents.json`. The Browser Agent is not a subagent; it is a built-in plugin.
225
226
+### Project LLM Configuration
227
+
228
+Projects can carry their own model settings without adding model data to `.a0proj/project.json`. The selected project model config is stored in the `_model_config` plugin's project-scoped path:
229
+
230
+```text
231
+/a0/usr/projects/<project_name>/.a0proj/plugins/_model_config/config.json
232
+```
233
+
234
+Project-only presets are stored next to that config:
235
+
236
+```text
237
+/a0/usr/projects/<project_name>/.a0proj/plugins/_model_config/presets.yaml
238
+```
239
+
240
+The project preset YAML is the same plain list used by global model presets:
241
+
242
+```yaml
243
+- name: Research
244
+ chat:
245
+ provider: openrouter
246
+ name: anthropic/claude-sonnet-4.6
247
+ api_base: ""
248
+ ctx_length: 200000
249
+ ctx_history: 0.7
250
+ vision: true
251
+ utility:
252
+ provider: openrouter
253
+ name: openai/gpt-5.4-mini
254
+ api_base: ""
255
+ ctx_length: 128000
256
+ ctx_input: 0.7
257
+```
258
+
259
+When you select a preset for a project, Agent Zero copies the preset's chat and utility settings into the project config. It does not keep a live link to the preset, and embedding model settings remain copied from the current effective config.
260
+
261
### Knowledge Files
262
263
Import project-specific knowledge documents:
helpers/projects.py
+59
@@ -76,18 +76,22 @@ def delete_project(name: str):
76
77
78
def create_project(name: str, data: BasicProjectData):
79
+ llm_data = data.get("llm") if isinstance(data, dict) else None
80
abs_path = files.create_dir_safe(
81
files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
82
)
83
create_project_meta_folders(name)
84
data = _normalizeBasicData(data)
85
save_project_header(name, data)
86
+ save_project_llm_settings(name, llm_data)
87
return name
88
89
90
def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData):
91
"""Clone a git repository as a new A0 project. Token is used only for cloning via http header."""
92
from helpers import git
93
+
94
+ llm_data = data.get("llm") if isinstance(data, dict) else None
95
96
abs_path = files.create_dir_safe(
97
files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
@@ -114,6 +118,8 @@ def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjec
118
data = _normalizeBasicData(data)
119
data["git_url"] = clean_url
120
save_project_header(actual_name, data)
121
+
122
+ save_project_llm_settings(actual_name, llm_data)
123
124
return actual_name
125
except Exception as e:
@@ -206,6 +212,8 @@ def _basic_data_to_edit_data(data: BasicProjectData) -> EditProjectData:
212
213
214
def update_project(name: str, data: EditProjectData):
215
+ llm_data = data.get("llm") if isinstance(data, dict) else None
216
+
217
# merge with current state
218
current = load_edit_project_data(name)
219
current.update(data)
@@ -219,6 +227,7 @@ def update_project(name: str, data: EditProjectData):
227
save_project_variables(name, current["variables"])
228
save_project_secrets(name, current["secrets"])
229
save_project_subagents(name, current["subagents"])
230
+ save_project_llm_settings(name, llm_data)
231
232
reactivate_project_in_chats(name)
233
return name
@@ -255,6 +264,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
264
},
265
)
266
data = _normalizeEditData(data)
267
+ data["llm"] = load_project_llm_data(name) # type: ignore[typeddict-unknown-key]
268
return data
269
270
@@ -268,6 +278,55 @@ def save_project_header(name: str, data: BasicProjectData):
278
files.write_file(abs_path, header)
279
280
281
+def load_project_llm_data(name: str) -> dict:
282
+ from plugins._model_config.helpers import model_config
283
+
284
+ config = model_config.normalize_config_for_save(
285
+ model_config.get_config(project_name=name) or {}
286
+ )
287
+ return {
288
+ "config": config,
289
+ "selected_preset": {
290
+ "scope": "current",
291
+ "project_name": name,
292
+ "name": "Current config",
293
+ },
294
+ "presets": model_config.get_combined_presets(name),
295
+ "global_presets": model_config.get_presets(),
296
+ "project_presets": model_config.get_project_presets(name),
297
+ }
298
+
299
+
300
+def save_project_llm_settings(name: str, llm_data: object):
301
+ if not isinstance(llm_data, dict):
302
+ return
303
+
304
+ from helpers import plugins
305
+ from plugins._model_config.helpers import model_config
306
+
307
+ project_presets = llm_data.get("project_presets")
308
+ if isinstance(project_presets, list):
309
+ model_config.save_presets(project_presets, project_name=name)
310
+
311
+ config_to_save = None
312
+ selected_preset = llm_data.get("selected_preset")
313
+ if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
314
+ "global",
315
+ "project",
316
+ }:
317
+ preset = model_config.resolve_preset_selection(selected_preset, project_name=name)
318
+ if preset:
319
+ base_config = model_config.get_config(project_name=name) or {}
320
+ config_to_save = model_config.build_config_from_preset(preset, base_config)
321
+
322
+ config = llm_data.get("config")
323
+ if config_to_save is None and isinstance(config, dict):
324
+ config_to_save = model_config.normalize_config_for_save(config)
325
+
326
+ if config_to_save is not None:
327
+ plugins.save_plugin_config("_model_config", name, "", config_to_save)
328
+
329
+
330
def get_active_projects_list():
331
return _get_projects_list(get_projects_parent_folder())
332
plugins/_a0_connector/api/v1/model_presets.py
+45
-5
@@ -10,20 +10,60 @@ class ModelPresets(connector_base.ProtectedConnectorApiHandler):
10
from plugins._model_config.helpers import model_config
11
12
action = str(input.get("action", "get")).strip() or "get"
13
+ project_name = str(input.get("project_name") or "").strip()
14
+ scope = str(input.get("scope") or "").strip()
15
16
if action == "get":
15
- presets = model_config.get_presets()
16
- return {"ok": True, "presets": presets}
17
+ if scope == "project":
18
+ presets = model_config.get_project_presets(project_name) if project_name else []
19
+ return {"ok": True, "presets": presets}
20
+ if scope == "combined" or project_name:
21
+ return {
22
+ "ok": True,
23
+ "presets": model_config.get_combined_presets(project_name or None),
24
+ "global_presets": model_config.get_presets(),
25
+ "project_presets": (
26
+ model_config.get_project_presets(project_name)
27
+ if project_name
28
+ else []
29
+ ),
30
+ }
31
+ return {"ok": True, "presets": model_config.get_presets()}
32
33
if action == "save":
34
presets = input.get("presets")
35
if not isinstance(presets, list):
36
return Response(status=400, response="presets must be an array")
22
- model_config.save_presets(presets)
23
- return {"ok": True, "presets": presets}
37
+ model_config.save_presets(
38
+ presets,
39
+ project_name=project_name if scope == "project" or project_name else None,
40
+ )
41
+ return {"ok": True, "presets": model_config.clean_presets_for_file(presets)}
42
43
if action == "reset":
26
- presets = model_config.reset_presets()
44
+ presets = model_config.reset_presets(
45
+ project_name=project_name if scope == "project" or project_name else None
46
+ )
47
return {"ok": True, "presets": presets}
48
49
+ if action == "resolve":
50
+ name = str(input.get("name") or "").strip()
51
+ if not name:
52
+ return Response(status=400, response="Missing preset name")
53
+ resolved = model_config.resolve_preset(
54
+ name,
55
+ scope=scope or "global",
56
+ project_name=project_name or None,
57
+ )
58
+ if not resolved:
59
+ return Response(status=404, response=f"Preset '{name}' not found")
60
+ return {
61
+ "ok": True,
62
+ "preset": {
63
+ **resolved,
64
+ "scope": scope or "global",
65
+ "project_name": project_name if scope == "project" else "",
66
+ },
67
+ }
68
+
69
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/README.md
+36
@@ -18,6 +18,7 @@ This plugin centralizes model selection and model-related settings for the appli
18
- Reads plugin config through the standard plugin config system with project and agent overrides.
19
- **Preset management**
20
- Loads presets from a user file when present and falls back to bundled defaults.
21
+ - Project presets can be stored beside a project's scoped model config.
22
- **Per-chat override**
23
- Allows a chat context to store a temporary override or preset reference in context data.
24
- **Model object construction**
@@ -47,6 +48,41 @@ This plugin centralizes model selection and model-related settings for the appli
48
- **Per-agent config**: `true`
49
- **Always enabled**: `true`
50
51
+## Project-Scoped Model Config
52
+
53
+Projects store copied model settings in the standard scoped plugin path:
54
+
55
+```text
56
+/a0/usr/projects/<project>/.a0proj/plugins/_model_config/config.json
57
+```
58
+
59
+Project-only presets live beside that config:
60
+
61
+```text
62
+/a0/usr/projects/<project>/.a0proj/plugins/_model_config/presets.yaml
63
+```
64
+
65
+The project preset file uses the same plain YAML list schema as global presets. It does not contain scope metadata:
66
+
67
+```yaml
68
+- name: Research
69
+ chat:
70
+ provider: openrouter
71
+ name: anthropic/claude-sonnet-4.6
72
+ api_base: ""
73
+ ctx_length: 200000
74
+ ctx_history: 0.7
75
+ vision: true
76
+ utility:
77
+ provider: openrouter
78
+ name: openai/gpt-5.4-mini
79
+ api_base: ""
80
+ ctx_length: 128000
81
+ ctx_input: 0.7
82
+```
83
+
84
+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.
85
+
86
## Plugin Metadata
87
88
- **Name**: `_model_config`
plugins/_model_config/api/model_presets.py
+46
-3
@@ -5,8 +5,26 @@ from plugins._model_config.helpers import model_config
5
class ModelPresets(ApiHandler):
6
async def process(self, input: dict, request: Request) -> dict | Response:
7
action = input.get("action", "get")
8
+ project_name = str(input.get("project_name") or "").strip()
9
+ scope = str(input.get("scope") or "").strip()
10
11
if action == "get":
12
+ if scope == "project":
13
+ presets = model_config.get_project_presets(project_name) if project_name else []
14
+ return {"ok": True, "presets": presets}
15
+
16
+ if scope == "combined" or project_name:
17
+ return {
18
+ "ok": True,
19
+ "presets": model_config.get_combined_presets(project_name or None),
20
+ "global_presets": model_config.get_presets(),
21
+ "project_presets": (
22
+ model_config.get_project_presets(project_name)
23
+ if project_name
24
+ else []
25
+ ),
26
+ }
27
+
28
presets = model_config.get_presets()
29
return {"ok": True, "presets": presets}
30
@@ -14,11 +32,36 @@ class ModelPresets(ApiHandler):
32
presets = input.get("presets")
33
if not isinstance(presets, list):
34
return Response(status=400, response="presets must be an array")
17
- model_config.save_presets(presets)
18
- return {"ok": True, "presets": presets}
35
+ model_config.save_presets(
36
+ presets,
37
+ project_name=project_name if scope == "project" or project_name else None,
38
+ )
39
+ return {"ok": True, "presets": model_config.clean_presets_for_file(presets)}
40
41
elif action == "reset":
21
- presets = model_config.reset_presets()
42
+ presets = model_config.reset_presets(
43
+ project_name=project_name if scope == "project" or project_name else None
44
+ )
45
return {"ok": True, "presets": presets}
46
47
+ elif action == "resolve":
48
+ name = str(input.get("name") or "").strip()
49
+ if not name:
50
+ return Response(status=400, response="Missing preset name")
51
+ resolved = model_config.resolve_preset(
52
+ name,
53
+ scope=scope or "global",
54
+ project_name=project_name or None,
55
+ )
56
+ if not resolved:
57
+ return Response(status=404, response=f"Preset '{name}' not found")
58
+ return {
59
+ "ok": True,
60
+ "preset": {
61
+ **resolved,
62
+ "scope": scope or "global",
63
+ "project_name": project_name if scope == "project" else "",
64
+ },
65
+ }
66
+
67
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/helpers/model_config.py
+159
-22
@@ -1,4 +1,6 @@
1
import os
2
+from copy import deepcopy
3
+
4
import models
5
from helpers import plugins, files
6
from helpers import yaml as yaml_helper
@@ -6,12 +8,18 @@ from helpers.providers import get_providers
8
9
PRESETS_FILE = "presets.yaml"
10
DEFAULT_PRESETS_FILE = "default_presets.yaml"
11
+PRESET_SCOPE_GLOBAL = "global"
12
+PRESET_SCOPE_PROJECT = "project"
13
LOCAL_PROVIDERS = {"ollama", "lm_studio"}
14
LOCAL_EMBEDDING = {"huggingface"}
15
16
13
-def _get_presets_path() -> str:
14
- """Return the path to the user's global presets file (usr/plugins/_model_config/presets.yaml)."""
17
+def _get_presets_path(project_name: str | None = None) -> str:
18
+ """Return the user-editable presets path for the requested scope."""
19
+ if project_name:
20
+ return plugins.determine_plugin_asset_path(
21
+ "_model_config", project_name, "", PRESETS_FILE
22
+ )
23
return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, "_model_config", PRESETS_FILE)
24
25
@@ -31,44 +39,173 @@ def get_config(agent=None, project_name=None, agent_profile=None):
39
) or {}
40
41
34
-def get_presets() -> list:
35
- """Get global model presets list (not scoped to project/agent)."""
36
- path = _get_presets_path()
42
+def _load_presets_from_path(path: str) -> list | None:
43
if files.exists(path):
44
data = yaml_helper.loads(files.read_file(path))
45
if isinstance(data, list):
46
return data
47
+ return None
48
+
49
+
50
+def _strip_ui_fields(value: dict, *, strip_api_key: bool) -> dict:
51
+ cleaned = deepcopy(value)
52
+ for key in list(cleaned.keys()):
53
+ if key.startswith("_"):
54
+ cleaned.pop(key, None)
55
+ if strip_api_key:
56
+ cleaned.pop("api_key", None)
57
+ return cleaned
58
+
59
+
60
+def _clean_preset_for_file(preset: dict) -> dict:
61
+ cleaned = {
62
+ "name": str(preset.get("name", "") or ""),
63
+ }
64
+ for slot in ("chat", "utility"):
65
+ slot_config = preset.get(slot)
66
+ if isinstance(slot_config, dict):
67
+ cleaned[slot] = _strip_ui_fields(slot_config, strip_api_key=False)
68
+ return cleaned
69
+
70
+
71
+def clean_presets_for_file(presets: list) -> list:
72
+ """Return presets without API/UI metadata, preserving the plain YAML schema."""
73
+ cleaned = []
74
+ for preset in presets:
75
+ if isinstance(preset, dict):
76
+ cleaned.append(_clean_preset_for_file(preset))
77
+ return cleaned
78
+
79
+
80
+def normalize_config_for_save(config: dict) -> dict:
81
+ """Remove UI-only fields and inline API keys before storing scoped config."""
82
+ cleaned = deepcopy(config or {})
83
+ for section_name in ("chat_model", "utility_model", "embedding_model"):
84
+ section = cleaned.get(section_name)
85
+ if isinstance(section, dict):
86
+ cleaned[section_name] = _strip_ui_fields(section, strip_api_key=True)
87
+ return cleaned
88
+
89
+
90
+def get_presets(project_name: str | None = None) -> list:
91
+ """Get global model presets list (not scoped to project/agent)."""
92
+ if project_name:
93
+ return get_project_presets(project_name)
94
+
95
+ path = _get_presets_path()
96
+ presets = _load_presets_from_path(path)
97
+ if presets is not None:
98
+ return presets
99
+
100
# Fall back to defaults bundled with the plugin
101
default_path = _get_default_presets_path()
43
- if default_path and files.exists(default_path):
44
- data = yaml_helper.loads(files.read_file(default_path))
45
- if isinstance(data, list):
46
- return data
47
- return []
102
+ default_presets = _load_presets_from_path(default_path) if default_path else None
103
+ return default_presets or []
104
105
50
-def save_presets(presets: list) -> None:
51
- """Save the global presets list."""
52
- path = _get_presets_path()
53
- files.write_file(path, yaml_helper.dumps(presets))
106
+def get_project_presets(project_name: str) -> list:
107
+ """Get presets stored beside the project-scoped _model_config config."""
108
+ return _load_presets_from_path(_get_presets_path(project_name)) or []
109
110
56
-def reset_presets() -> list:
57
- """Delete user presets so get_presets() falls back to bundled defaults. Returns the default presets."""
58
- path = _get_presets_path()
111
+def _with_preset_metadata(preset: dict, scope: str, project_name: str = "") -> dict:
112
+ item = deepcopy(preset)
113
+ item["scope"] = scope
114
+ item["project_name"] = project_name if scope == PRESET_SCOPE_PROJECT else ""
115
+ item["name"] = str(item.get("name", "") or "")
116
+ return item
117
+
118
+
119
+def get_combined_presets(project_name: str | None = None) -> list:
120
+ """Get presets with explicit API metadata for unambiguous UI/API selection."""
121
+ presets = [
122
+ _with_preset_metadata(preset, PRESET_SCOPE_GLOBAL)
123
+ for preset in get_presets()
124
+ if isinstance(preset, dict)
125
+ ]
126
+ if project_name:
127
+ presets.extend(
128
+ _with_preset_metadata(preset, PRESET_SCOPE_PROJECT, project_name)
129
+ for preset in get_project_presets(project_name)
130
+ if isinstance(preset, dict)
131
+ )
132
+ return presets
133
+
134
+
135
+def save_presets(presets: list, project_name: str | None = None) -> None:
136
+ """Save the presets list for the requested scope."""
137
+ path = _get_presets_path(project_name)
138
+ files.write_file(path, yaml_helper.dumps(clean_presets_for_file(presets)))
139
+
140
+
141
+def reset_presets(project_name: str | None = None) -> list:
142
+ """Delete user presets for the scope. Global reset falls back to bundled defaults."""
143
+ path = _get_presets_path(project_name)
144
if os.path.exists(path):
145
os.remove(path)
61
- return get_presets()
62
-
146
+ return get_project_presets(project_name) if project_name else get_presets()
147
+
148
+
149
+def resolve_preset(
150
+ name: str,
151
+ *,
152
+ scope: str = PRESET_SCOPE_GLOBAL,
153
+ project_name: str | None = None,
154
+) -> dict | None:
155
+ """Resolve a preset by explicit scope so same-name presets are unambiguous."""
156
+ if scope == PRESET_SCOPE_PROJECT:
157
+ if not project_name:
158
+ return None
159
+ presets = get_project_presets(project_name)
160
+ else:
161
+ presets = get_presets()
162
64
-def get_preset_by_name(name: str) -> dict | None:
65
- """Find a preset by name from the global presets list."""
66
- for p in get_presets():
163
+ for p in presets:
164
if p.get("name") == name:
165
return p
166
return None
167
168
169
+def resolve_preset_selection(selection: dict | str, project_name: str | None = None) -> dict | None:
170
+ """Resolve a UI/API preset selection payload to a preset dict."""
171
+ if isinstance(selection, str):
172
+ return resolve_preset(selection)
173
+ if not isinstance(selection, dict):
174
+ return None
175
+
176
+ scope = str(selection.get("scope") or PRESET_SCOPE_GLOBAL)
177
+ if scope == "current":
178
+ return None
179
+ name = str(selection.get("name") or "")
180
+ selected_project = str(selection.get("project_name") or project_name or "")
181
+ return resolve_preset(name, scope=scope, project_name=selected_project or None)
182
+
183
+
184
+def get_preset_by_name(
185
+ name: str,
186
+ *,
187
+ scope: str = PRESET_SCOPE_GLOBAL,
188
+ project_name: str | None = None,
189
+) -> dict | None:
190
+ """Find a preset by name. Defaults to global presets for legacy callers."""
191
+ return resolve_preset(name, scope=scope, project_name=project_name)
192
+
193
+
194
+def build_config_from_preset(preset: dict, base_config: dict) -> dict:
195
+ """Copy chat/utility settings from a preset into a standalone model config."""
196
+ config = normalize_config_for_save(base_config)
197
+
198
+ chat = preset.get("chat") if isinstance(preset, dict) else None
199
+ if isinstance(chat, dict):
200
+ config["chat_model"] = _strip_ui_fields(chat, strip_api_key=True)
201
+
202
+ utility = preset.get("utility") if isinstance(preset, dict) else None
203
+ if isinstance(utility, dict) and (utility.get("provider") or utility.get("name")):
204
+ config["utility_model"] = _strip_ui_fields(utility, strip_api_key=True)
205
+
206
+ return config
207
+
208
+
209
def _resolve_override(agent) -> dict | None:
210
"""Resolve the active per-chat override config dict.
211
Supports both raw override dicts and preset-based overrides.
tests/test_model_config_api_keys.py
+14
-4
@@ -85,13 +85,23 @@ async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
85
86
def test_model_config_frontend_tracks_inline_api_key_edits():
87
store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
88
+ api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js"
89
composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js"
90
config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
91
+ model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
92
modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
93
92
- store_content = store_path.read_text(encoding="utf-8")
94
+ store_content = (
95
+ store_path.read_text(encoding="utf-8")
96
+ + "\n"
97
+ + api_keys_mixin_path.read_text(encoding="utf-8")
98
+ )
99
composer_store_content = composer_store_path.read_text(encoding="utf-8")
94
- config_content = config_path.read_text(encoding="utf-8")
100
+ config_content = (
101
+ config_path.read_text(encoding="utf-8")
102
+ + "\n"
103
+ + model_field_path.read_text(encoding="utf-8")
104
+ )
105
modal_content = modal_path.read_text(encoding="utf-8")
106
107
assert "apiKeyDirty" in store_content
@@ -102,6 +112,6 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
112
assert 'callJsonApi("/banners"' in composer_store_content
113
assert "/plugins/_model_config/missing_api_key_status" not in composer_store_content
114
assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
105
- assert '@input="$store.modelConfig.touchApiKey(config[section.key].provider)"' in config_content
106
- assert "updates[provider] = this.keys[provider] || '';" in modal_content
115
+ assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
116
+ assert "persistAllDirtyApiKeys()" in modal_content
117
assert "$store.modelConfig.resetApiKeyDrafts();" in modal_content
tests/test_model_config_project_presets.py
new
+289
@@ -0,0 +1,289 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import sys
5
+import threading
6
+import types
7
+from pathlib import Path
8
+
9
+import pytest
10
+from flask import Flask
11
+
12
+
13
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
14
+if str(PROJECT_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(PROJECT_ROOT))
16
+
17
+sys.modules.setdefault("giturlparse", types.SimpleNamespace(parse=lambda *args, **kwargs: None))
18
+
19
+
20
+class _DummyObserver:
21
+ def __init__(self):
22
+ self._alive = False
23
+
24
+ def is_alive(self):
25
+ return self._alive
26
+
27
+ def start(self):
28
+ self._alive = True
29
+
30
+ def stop(self):
31
+ self._alive = False
32
+
33
+ def join(self, *args, **kwargs):
34
+ return None
35
+
36
+ def unschedule_all(self):
37
+ return None
38
+
39
+ def schedule(self, *args, **kwargs):
40
+ return None
41
+
42
+
43
+watchdog = types.ModuleType("watchdog")
44
+watchdog.observers = types.SimpleNamespace(Observer=_DummyObserver)
45
+watchdog.events = types.SimpleNamespace(FileSystemEventHandler=object)
46
+sys.modules.setdefault("watchdog", watchdog)
47
+sys.modules.setdefault("watchdog.observers", watchdog.observers)
48
+sys.modules.setdefault("watchdog.events", watchdog.events)
49
+
50
+
51
+def _prepare_a0_tree(monkeypatch, tmp_path: Path):
52
+ from helpers import files, plugins
53
+
54
+ monkeypatch.setattr(files, "_base_dir", str(tmp_path))
55
+ monkeypatch.setattr(
56
+ plugins,
57
+ "call_plugin_hook",
58
+ lambda plugin_name, hook_name, default=None, **kwargs: default,
59
+ )
60
+
61
+ plugin_dir = tmp_path / "plugins" / "_model_config"
62
+ plugin_dir.mkdir(parents=True)
63
+ (plugin_dir / "plugin.yaml").write_text(
64
+ "name: _model_config\nper_project_config: true\nper_agent_config: true\n",
65
+ encoding="utf-8",
66
+ )
67
+ (plugin_dir / "default_presets.yaml").write_text(
68
+ """
69
+- name: Default Balance
70
+ chat:
71
+ provider: openrouter
72
+ name: default-chat
73
+ utility:
74
+ provider: openrouter
75
+ name: default-utility
76
+""".lstrip(),
77
+ encoding="utf-8",
78
+ )
79
+ (plugin_dir / "default_config.yaml").write_text(
80
+ """
81
+allow_chat_override: true
82
+chat_model:
83
+ provider: openrouter
84
+ name: configured-chat
85
+utility_model:
86
+ provider: openrouter
87
+ name: configured-utility
88
+embedding_model:
89
+ provider: huggingface
90
+ name: configured-embedding
91
+""".lstrip(),
92
+ encoding="utf-8",
93
+ )
94
+ (tmp_path / "usr" / "projects").mkdir(parents=True)
95
+
96
+
97
+def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_path):
98
+ _prepare_a0_tree(monkeypatch, tmp_path)
99
+
100
+ from plugins._model_config.helpers import model_config
101
+
102
+ assert model_config.get_presets()[0]["name"] == "Default Balance"
103
+
104
+ model_config.save_presets(
105
+ [
106
+ {
107
+ "name": "Global One",
108
+ "scope": "project",
109
+ "project_name": "ignored",
110
+ "chat": {"provider": "openai", "name": "gpt-test", "_kwargs_text": ""},
111
+ }
112
+ ]
113
+ )
114
+
115
+ presets = model_config.get_presets()
116
+ assert presets == [
117
+ {"name": "Global One", "chat": {"provider": "openai", "name": "gpt-test"}}
118
+ ]
119
+
120
+ saved_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
121
+ assert "scope:" not in saved_path.read_text(encoding="utf-8")
122
+
123
+ model_config.save_presets([])
124
+ assert model_config.get_presets() == []
125
+
126
+ assert model_config.reset_presets()[0]["name"] == "Default Balance"
127
+
128
+
129
+def test_project_presets_are_separate_and_resolve_by_scope(monkeypatch, tmp_path):
130
+ _prepare_a0_tree(monkeypatch, tmp_path)
131
+
132
+ from helpers import projects
133
+ from plugins._model_config.helpers import model_config
134
+
135
+ projects.create_project("demo", {"title": "Demo"})
136
+ model_config.save_presets(
137
+ [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
138
+ )
139
+ model_config.save_presets(
140
+ [{"name": "Shared", "chat": {"provider": "project", "name": "chat"}}],
141
+ project_name="demo",
142
+ )
143
+
144
+ assert model_config.get_presets()[0]["chat"]["provider"] == "global"
145
+ assert model_config.get_project_presets("demo")[0]["chat"]["provider"] == "project"
146
+
147
+ combined = model_config.get_combined_presets("demo")
148
+ assert [(item["scope"], item["project_name"], item["name"]) for item in combined] == [
149
+ ("global", "", "Shared"),
150
+ ("project", "demo", "Shared"),
151
+ ]
152
+ assert model_config.resolve_preset("Shared", scope="global")["chat"]["provider"] == "global"
153
+ assert (
154
+ model_config.resolve_preset("Shared", scope="project", project_name="demo")["chat"]["provider"]
155
+ == "project"
156
+ )
157
+
158
+
159
+@pytest.mark.asyncio
160
+async def test_model_presets_api_returns_global_or_combined_by_project(monkeypatch, tmp_path):
161
+ _prepare_a0_tree(monkeypatch, tmp_path)
162
+
163
+ from helpers import projects
164
+ from plugins._model_config.api.model_presets import ModelPresets
165
+ from plugins._model_config.helpers import model_config
166
+
167
+ projects.create_project("demo", {"title": "Demo"})
168
+ model_config.save_presets(
169
+ [{"name": "Global", "chat": {"provider": "global", "name": "chat"}}]
170
+ )
171
+ model_config.save_presets(
172
+ [{"name": "Project", "chat": {"provider": "project", "name": "chat"}}],
173
+ project_name="demo",
174
+ )
175
+
176
+ handler = ModelPresets(Flask(__name__), threading.Lock())
177
+ global_response = await handler.process({"action": "get"}, None)
178
+ assert global_response["presets"] == [
179
+ {"name": "Global", "chat": {"provider": "global", "name": "chat"}}
180
+ ]
181
+ assert "scope" not in global_response["presets"][0]
182
+
183
+ project_response = await handler.process({"action": "get", "project_name": "demo"}, None)
184
+ assert [(p["scope"], p["name"]) for p in project_response["presets"]] == [
185
+ ("global", "Global"),
186
+ ("project", "Project"),
187
+ ]
188
+
189
+
190
+def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch, tmp_path):
191
+ _prepare_a0_tree(monkeypatch, tmp_path)
192
+
193
+ from helpers import projects
194
+ from plugins._model_config.helpers import model_config
195
+
196
+ model_config.save_presets(
197
+ [
198
+ {
199
+ "name": "Research",
200
+ "chat": {"provider": "anthropic", "name": "claude-research"},
201
+ "utility": {"provider": "openai", "name": "utility-research"},
202
+ }
203
+ ]
204
+ )
205
+
206
+ projects.create_project(
207
+ "demo",
208
+ {
209
+ "title": "Demo",
210
+ "llm": {
211
+ "selected_preset": {"scope": "global", "name": "Research"},
212
+ },
213
+ },
214
+ )
215
+
216
+ config_path = (
217
+ tmp_path
218
+ / "usr"
219
+ / "projects"
220
+ / "demo"
221
+ / ".a0proj"
222
+ / "plugins"
223
+ / "_model_config"
224
+ / "config.json"
225
+ )
226
+ config = json.loads(config_path.read_text(encoding="utf-8"))
227
+ assert config["chat_model"]["name"] == "claude-research"
228
+ assert config["utility_model"]["name"] == "utility-research"
229
+ assert config["embedding_model"]["name"] == "configured-embedding"
230
+
231
+ project_json = (
232
+ tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "project.json"
233
+ ).read_text(encoding="utf-8")
234
+ assert "llm" not in project_json
235
+ assert "_model_config" not in project_json
236
+
237
+
238
+def test_project_save_disambiguates_same_name_project_preset(monkeypatch, tmp_path):
239
+ _prepare_a0_tree(monkeypatch, tmp_path)
240
+
241
+ from helpers import projects
242
+ from plugins._model_config.helpers import model_config
243
+
244
+ model_config.save_presets(
245
+ [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
246
+ )
247
+ projects.create_project(
248
+ "demo",
249
+ {
250
+ "title": "Demo",
251
+ "llm": {
252
+ "selected_preset": {
253
+ "scope": "project",
254
+ "project_name": "demo",
255
+ "name": "Shared",
256
+ },
257
+ "project_presets": [
258
+ {"name": "Shared", "chat": {"provider": "project", "name": "chat"}}
259
+ ],
260
+ },
261
+ },
262
+ )
263
+
264
+ config = json.loads(
265
+ (
266
+ tmp_path
267
+ / "usr"
268
+ / "projects"
269
+ / "demo"
270
+ / ".a0proj"
271
+ / "plugins"
272
+ / "_model_config"
273
+ / "config.json"
274
+ ).read_text(encoding="utf-8")
275
+ )
276
+ assert config["chat_model"]["provider"] == "project"
277
+
278
+ presets_yaml = (
279
+ tmp_path
280
+ / "usr"
281
+ / "projects"
282
+ / "demo"
283
+ / ".a0proj"
284
+ / "plugins"
285
+ / "_model_config"
286
+ / "presets.yaml"
287
+ ).read_text(encoding="utf-8")
288
+ assert "scope:" not in presets_yaml
289
+ assert "project_name:" not in presets_yaml
webui/components/projects/project-create.html
+15
-1
@@ -36,6 +36,14 @@
36
</div>
37
</template>
38
39
+ <div class="project-detail-inline-section">
40
+ <div class="project-detail-header">
41
+ <span class="projects-project-card-title">LLM</span>
42
+ </div>
43
+ <x-component path="projects/project-edit-llm.html">
44
+ </x-component>
45
+ </div>
46
+
47
<div class="buttons-right">
48
<button type="button" class="button cancel"
49
@click="$store.projects.cancelCreate()"
@@ -74,6 +82,12 @@
82
margin-bottom: 1em;
83
}
84
85
+ .project-detail-inline-section {
86
+ border-top: 1px solid var(--color-border);
87
+ margin-top: 1em;
88
+ padding-top: 1em;
89
+ }
90
+
91
.button-loading {
92
display: inline-flex;
93
align-items: center;
@@ -100,4 +114,4 @@
114
}
115
</style>
116
103
-</html>
\ No newline at end of file
117
+</html>
webui/components/projects/project-edit-llm.html
new
+90
@@ -0,0 +1,90 @@
1
+<html>
2
+
3
+<head>
4
+ <title>Project LLM</title>
5
+ <script type="module">
6
+ import { store } from "/components/projects/projects-store.js";
7
+ import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
8
+ </script>
9
+</head>
10
+
11
+<body>
12
+ <div x-data x-init="await $store.modelConfig.ensureLoaded()">
13
+ <template x-if="$store.projects && $store.projects.selectedProject && $store.projects.selectedProject.llm && $store.modelConfig._loaded">
14
+ <div>
15
+ <div class="projects-form-group">
16
+ <label class="projects-form-label">Preset</label>
17
+ <span class="projects-form-description">Pick a preset to copy its main and utility model settings into this project. Embeddings stay with the current project config.</span>
18
+ <select class="projects-form-select"
19
+ x-model="$store.projects.selectedProject.llm.preset_key"
20
+ @change="$store.projects.applySelectedLlmPreset()">
21
+ <option value="current">Current config</option>
22
+ <optgroup label="Global presets">
23
+ <template x-for="preset in $store.projects.selectedProject.llm.global_presets" :key="$store.projects.getLlmPresetKey(preset)">
24
+ <option :value="$store.projects.getLlmPresetKey(preset)" x-text="preset.name"></option>
25
+ </template>
26
+ </optgroup>
27
+ <optgroup label="This project's presets">
28
+ <template x-for="preset in $store.projects.selectedProject.llm.project_presets" :key="$store.projects.getLlmPresetKey(preset)">
29
+ <option :value="$store.projects.getLlmPresetKey(preset)" x-text="preset.name"></option>
30
+ </template>
31
+ </optgroup>
32
+ </select>
33
+ </div>
34
+
35
+ <div class="projects-form-group">
36
+ <label class="projects-form-label">Project preset</label>
37
+ <template x-if="$store.projects.selectedProject._meta.creating">
38
+ <span class="projects-form-description">Create or update a preset stored in this project's <strong>_model_config/presets.yaml</strong> when the project is saved.</span>
39
+ </template>
40
+ <template x-if="!$store.projects.selectedProject._meta.creating">
41
+ <span class="projects-form-description">Create or update a preset stored in <strong><span x-text="$store.projects.getSelectedAbsPath('.a0proj','plugins','_model_config','presets.yaml')"></span></strong>.</span>
42
+ </template>
43
+ <div class="projects-input-with-button-wrapper">
44
+ <input class="projects-form-input" type="text"
45
+ x-model="$store.projects.selectedProject.llm.new_preset_name"
46
+ placeholder="Preset name">
47
+ <button type="button" class="button icon-button"
48
+ :disabled="!$store.projects.selectedProject.llm.new_preset_name.trim()"
49
+ @click="$store.projects.saveSelectedLlmProjectPreset()">
50
+ <span class="icon material-symbols-outlined">save</span>
51
+ <span>Save preset</span>
52
+ </button>
53
+ </div>
54
+ </div>
55
+
56
+ <template x-for="section in $store.modelConfig.MODEL_SECTIONS" :key="section.key">
57
+ <div class="project-llm-model-section"
58
+ @input.capture="$store.projects.markLlmCurrent()"
59
+ @change.capture="$store.projects.markLlmCurrent()"
60
+ x-data="{ get model() { return $store.projects.selectedProject.llm.config[section.key]; }, get modelType() { return section.key.replace('_model', ''); }, get providers() { return $store.modelConfig.getProviders(section.key); }, get searchType() { return $store.modelConfig.getSearchType(section.key); }, apiKeyMode: 'store' }">
61
+ <div class="project-llm-section-title" x-text="section.title"></div>
62
+ <div class="project-llm-section-description" x-text="section.desc"></div>
63
+ <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
64
+ </div>
65
+ </template>
66
+ </div>
67
+ </template>
68
+ </div>
69
+</body>
70
+<style>
71
+ .project-llm-model-section {
72
+ border-top: 1px dashed var(--color-border);
73
+ margin-top: 1em;
74
+ padding-top: 1em;
75
+ }
76
+
77
+ .project-llm-section-title {
78
+ font-size: 0.95rem;
79
+ font-weight: 700;
80
+ margin-bottom: 0.25em;
81
+ }
82
+
83
+ .project-llm-section-description {
84
+ font-size: 0.8rem;
85
+ opacity: 0.75;
86
+ margin-bottom: 0.8em;
87
+ }
88
+</style>
89
+
90
+</html>
webui/components/projects/project-edit.html
+9
-1
@@ -34,6 +34,14 @@
34
</x-component>
35
</div>
36
37
+ <div class="project-detail">
38
+ <div class="project-detail-header">
39
+ <span class="projects-project-card-title">LLM</span>
40
+ </div>
41
+ <x-component path="projects/project-edit-llm.html">
42
+ </x-component>
43
+ </div>
44
+
45
<div class="project-detail">
46
<div class="project-detail-header">
47
<span class="projects-project-card-title">Instructions</span>
@@ -98,4 +106,4 @@
106
}
107
</style>
108
101
-</html>
\ No newline at end of file
109
+</html>
webui/components/projects/projects-store.js
+203
-2
@@ -5,6 +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";
9
import * as shortcuts from "/js/shortcuts.js";
10
import { showConfirmDialog } from "/js/confirmDialog.js";
11
@@ -93,7 +94,7 @@ const model = {
94
},
95
96
async openCreateModal() {
96
- this.selectedProject = this._createNewProjectData();
97
+ this.selectedProject = await this._createNewProjectData();
98
await modals.openModal(createModal);
99
this.selectedProject = null;
100
},
@@ -162,6 +163,7 @@ const model = {
163
color: project.color,
164
git_url: project.git_url,
165
git_token: project.git_token || "",
166
+ llm: project.llm || null,
167
},
168
});
169
@@ -396,7 +398,7 @@ const model = {
398
}
399
},
400
399
- _createNewProjectData() {
401
+ async _createNewProjectData() {
402
return {
403
_meta: {
404
creating: true,
@@ -408,6 +410,7 @@ const model = {
410
color: "",
411
git_url: "",
412
git_token: "",
413
+ llm: await this._createProjectLlmData(""),
414
};
415
},
416
@@ -423,9 +426,207 @@ const model = {
426
creating: false,
427
},
428
...projectData,
429
+ llm: this._normalizeProjectLlmData(projectData.llm, name),
430
};
431
},
432
433
+ async _createProjectLlmData(projectName) {
434
+ await modelConfigStore.ensureLoaded();
435
+ const configResult = await api.callJsonApi("/plugins/_model_config/model_config_get", {
436
+ project_name: projectName || "",
437
+ });
438
+ const presetsResult = await api.callJsonApi("/plugins/_model_config/model_presets", {
439
+ action: "get",
440
+ project_name: projectName || "",
441
+ scope: projectName ? "combined" : "global",
442
+ });
443
+ return this._normalizeProjectLlmData({
444
+ config: configResult.config || {},
445
+ selected_preset: {
446
+ scope: "current",
447
+ project_name: projectName || "",
448
+ name: "Current config",
449
+ },
450
+ global_presets: presetsResult.global_presets || presetsResult.presets || [],
451
+ project_presets: presetsResult.project_presets || [],
452
+ presets: presetsResult.presets || [],
453
+ }, projectName);
454
+ },
455
+
456
+ _normalizeProjectLlmData(raw, projectName) {
457
+ const data = raw || {};
458
+ const config = JSON.parse(JSON.stringify(data.config || {}));
459
+ config.chat_model = config.chat_model || {};
460
+ config.utility_model = config.utility_model || {};
461
+ config.embedding_model = config.embedding_model || {};
462
+ modelConfigStore.initConfigFields(config);
463
+
464
+ const globalPresets = this._normalizePresetsWithScope(
465
+ data.global_presets || [],
466
+ "global",
467
+ ""
468
+ );
469
+ const projectPresets = this._normalizePresetsWithScope(
470
+ data.project_presets || [],
471
+ "project",
472
+ projectName || ""
473
+ );
474
+ const presets = [
475
+ ...globalPresets,
476
+ ...projectPresets,
477
+ ];
478
+ const selected = data.selected_preset || {
479
+ scope: "current",
480
+ project_name: projectName || "",
481
+ name: "Current config",
482
+ };
483
+ return {
484
+ config,
485
+ selected_preset: selected,
486
+ preset_key: this.getLlmPresetKey(selected),
487
+ global_presets: globalPresets,
488
+ project_presets: projectPresets,
489
+ presets,
490
+ new_preset_name: "",
491
+ };
492
+ },
493
+
494
+ _normalizePresetsWithScope(presets, defaultScope, projectName) {
495
+ return modelConfigStore._normalizePresets(presets || []).map((preset, index) => {
496
+ const raw = presets[index] || {};
497
+ return {
498
+ ...preset,
499
+ scope: raw.scope || defaultScope,
500
+ project_name: raw.project_name || (defaultScope === "project" ? projectName : ""),
501
+ };
502
+ });
503
+ },
504
+
505
+ getLlmPresetKey(preset) {
506
+ if (!preset || preset.scope === "current") return "current";
507
+ return `${preset.scope || "global"}|${preset.project_name || ""}|${preset.name || ""}`;
508
+ },
509
+
510
+ _findLlmPresetByKey(key) {
511
+ const llm = this.selectedProject?.llm;
512
+ if (!llm || key === "current") return null;
513
+ return (llm.presets || []).find((preset) => this.getLlmPresetKey(preset) === key) || null;
514
+ },
515
+
516
+ applySelectedLlmPreset() {
517
+ const llm = this.selectedProject?.llm;
518
+ if (!llm) return;
519
+ if (llm.preset_key === "current") {
520
+ llm.selected_preset = {
521
+ scope: "current",
522
+ project_name: this.selectedProject?.name || "",
523
+ name: "Current config",
524
+ };
525
+ return;
526
+ }
527
+ const preset = this._findLlmPresetByKey(llm.preset_key);
528
+ if (!preset) return;
529
+ llm.selected_preset = {
530
+ scope: preset.scope || "global",
531
+ project_name: preset.project_name || "",
532
+ name: preset.name || "",
533
+ };
534
+ llm.config = this._configFromPreset(preset, llm.config || {});
535
+ modelConfigStore.initConfigFields(llm.config);
536
+ },
537
+
538
+ markLlmCurrent() {
539
+ const llm = this.selectedProject?.llm;
540
+ if (!llm) return;
541
+ llm.selected_preset = {
542
+ scope: "current",
543
+ project_name: this.selectedProject?.name || "",
544
+ name: "Current config",
545
+ };
546
+ llm.preset_key = "current";
547
+ },
548
+
549
+ _configFromPreset(preset, baseConfig) {
550
+ const config = JSON.parse(JSON.stringify(baseConfig || {}));
551
+ if (preset.chat) config.chat_model = this._cleanModelSlot(preset.chat, true);
552
+ if (preset.utility?.provider || preset.utility?.name) {
553
+ config.utility_model = this._cleanModelSlot(preset.utility, true);
554
+ }
555
+ return config;
556
+ },
557
+
558
+ _cleanModelSlot(slot, stripApiKey = true) {
559
+ const clean = JSON.parse(JSON.stringify(slot || {}));
560
+ for (const key of Object.keys(clean)) {
561
+ if (key.startsWith("_")) delete clean[key];
562
+ }
563
+ if (stripApiKey) delete clean.api_key;
564
+ return clean;
565
+ },
566
+
567
+ _presetFromLlmConfig(name, config) {
568
+ return {
569
+ name,
570
+ chat: this._cleanModelSlot(config?.chat_model || {}, true),
571
+ utility: this._cleanModelSlot(config?.utility_model || {}, true),
572
+ };
573
+ },
574
+
575
+ async saveSelectedLlmProjectPreset() {
576
+ const project = this.selectedProject;
577
+ const llm = project?.llm;
578
+ const name = (llm?.new_preset_name || "").trim();
579
+ if (!project || !llm || !name) return;
580
+
581
+ const preset = {
582
+ ...this._presetFromLlmConfig(name, llm.config || {}),
583
+ scope: "project",
584
+ project_name: project.name || "",
585
+ };
586
+ const projectPresets = llm.project_presets || (llm.project_presets = []);
587
+ const existingIndex = projectPresets.findIndex((p) => p.name === name);
588
+ if (existingIndex >= 0) projectPresets.splice(existingIndex, 1, preset);
589
+ else projectPresets.push(preset);
590
+
591
+ llm.presets = [
592
+ ...(llm.global_presets || []),
593
+ ...(llm.project_presets || []),
594
+ ];
595
+ llm.selected_preset = {
596
+ scope: "project",
597
+ project_name: project.name || "",
598
+ name,
599
+ };
600
+ llm.preset_key = this.getLlmPresetKey(llm.selected_preset);
601
+ llm.new_preset_name = "";
602
+
603
+ if (!project._meta?.creating && project.name) {
604
+ await api.callJsonApi("/plugins/_model_config/model_presets", {
605
+ action: "save",
606
+ scope: "project",
607
+ project_name: project.name,
608
+ presets: llm.project_presets,
609
+ });
610
+ notifications.toastFrontendSuccess(
611
+ "Project preset saved",
612
+ "LLM preset",
613
+ 3,
614
+ "projects_llm",
615
+ notifications.NotificationPriority.NORMAL,
616
+ true
617
+ );
618
+ } else {
619
+ notifications.toastFrontendSuccess(
620
+ "Project preset ready",
621
+ "LLM preset",
622
+ 3,
623
+ "projects_llm",
624
+ notifications.NotificationPriority.NORMAL,
625
+ true
626
+ );
627
+ }
628
+ },
629
+
630
async browseSelected(...relPath) {
631
const path = this.getSelectedAbsPath(...relPath);
632
return await browserStore.open(path);