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