Unify global model presets

Make model presets a single global collection and treat presets as reusable setups (main, utility, embedding). Persisted scope configs now store only a preset name (Default is always present and immutable); project-scoped preset definitions were removed and attempts to edit/reset project presets are rejected. Add embedding-model support throughout: include embedding slot in presets, surface embedding in the switcher and override APIs, and notify extensions when the effective embedding model changes. Implement rename/retire propagation to update plugin configs, saved chats, and live AgentContext objects when preset names change or presets are removed. Update CLI/integration commands to use an "inherit" action for returning to the scoped preset and to report the effective scoped-or-chat preset. Add startup migration/bootstrap to initialize presets from remote or bundled fallback and numerous docs/UI text updates to reflect the new preset model. Extra: refactor config resolution helpers and add safety checks (validation, atomic writes) when saving presets.

frdel committed Jul 17, 2026 at 12:16 UTC 15a2b553beaa037d3617cee3caa0ba9e27aad509
49 files changed +2781 -1279
README.md
+2 -2
@@ -33,7 +33,7 @@ Agent Zero is an open agent framework for work that needs more than chat: a Dock
33 | **Browser DOM annotation** | Click page elements and turn them into inspect, change, lift, or review instructions. |
34 | **Live document cowork** | Edit Markdown, Writer, Spreadsheet, and Presentation files together instead of losing work in chat. |
35 | **Plugin Hub** | Install 100+ community plugins or publish your own extension points. |
36 -| **Projects and memory** | Keep files, instructions, secrets, memories, repositories, and model presets isolated per project. |
36 +| **Projects and memory** | Keep files, instructions, secrets, memories, repositories, and model-preset choices isolated per project. |
37 | **Host-machine bridge** | Connect with the A0 CLI so the same agent can work in your real local repositories. |
38 | **Multi-agent cooperation** | Let agents delegate research, coding, analysis, or review tasks to focused subagents. |
39 | **Transparent internals** | Prompts, tools, plugins, skills, and settings are inspectable and editable. |
@@ -223,7 +223,7 @@ For full setup, see the [A0 CLI Connector guide](https://www.agent-zero.ai/p/doc
223
224 ## Projects, Skills, Agent Profiles, and Model Presets
225
226 -**Projects** isolate workspaces, instructions, memory, secrets, knowledge, repositories, and model presets. Clone a public or private Git repo into a project and give the agent context that belongs to that work alone.
226 +**Projects** isolate workspaces, instructions, memory, secrets, knowledge, repositories, and model-preset choices. Clone a public or private Git repo into a project and give the agent context that belongs to that work alone.
227
228 **Skills** can be loaded on demand by Agent Zero, or pinned from the chat input when you want a specific procedure to stay active.
229
docs/guides/model-presets.md
+38 -12
@@ -1,6 +1,7 @@
1 # Model Presets
2
3 -Model Presets are named shortcuts for model choices.
3 +Model Presets are named, reusable model setups. Every setup contains a main,
4 +utility, and embedding model.
5
6 Use them when you want to switch a chat between setups such as "fast", "cheap",
7 "local", "balanced", or "maximum power" without rebuilding the settings each
@@ -9,6 +10,8 @@ time.
10 ## Choose A Preset
11
12 The preset menu is the first dropdown on the left side of the chat status bar.
13 +Its closed label shows the preset and the short main-model name, without the
14 +provider prefix.
15
16 ![Model preset selector](../res/usage/webui/model-preset-selector.png)
17
@@ -16,20 +19,35 @@ The preset menu is the first dropdown on the left side of the chat status bar.
19 2. Click the current preset name.
20 3. Choose the preset you want.
21
19 -The selected preset affects the current chat.
22 +The selected preset affects the current chat. Choose **Use scoped preset** to
23 +return the chat to the default selected for its project and agent profile. If no
24 +more specific choice exists, Agent Zero uses the global preset.
25 +
26 +The **Default** preset is always available. You can edit its models, but you
27 +cannot rename or delete it.
28 +
29 +## Initial Presets
30 +
31 +When no saved preset collection exists at startup, Agent Zero downloads the
32 +curated **Default**, **Efficiency**, and **Power** presets from the public
33 +[`agent0ai/a0-presets`](https://github.com/agent0ai/a0-presets) repository. If
34 +GitHub is unavailable or the file is invalid, Agent Zero saves its bundled
35 +plugin fallback instead. Existing saved presets short-circuit this check and
36 +are never replaced.
37
38 ## Edit Presets
39
40 Click **Edit presets** from the same menu.
41
25 -![Model presets editor](../res/usage/webui/model-presets-editor.png)
26 -
42 From this screen you can:
43
44 - rename presets;
45 - choose the main model;
46 - choose the utility model;
47 +- choose the embedding model;
48 +- enter the shared API key for each model provider;
49 - open API key settings;
50 +- add or delete presets (except **Default**);
51 - save the preset list.
52
53 Think of a preset as a label on a model setup.
@@ -38,17 +56,14 @@ Think of a preset as a label on a model setup.
56 | --- | --- |
57 | **Main model** | The model that does the main conversation and reasoning. |
58 | **Utility model** | A smaller helper model for lighter internal tasks. |
59 +| **Embedding model** | The model that creates vectors for memory and knowledge retrieval. |
60
42 -Presets can be partial. If a preset does not set a utility model, Agent Zero
43 -uses your configured Utility Model. If a preset changes the utility model but
44 -does not set advanced fields, your configured context window, rate limits, and
45 -other advanced values stay in place.
61 +The editor presents each preset as one complete setup. Internally, an existing
62 +non-default preset may inherit omitted advanced values from **Default**.
63
64 ## Add A Preset
65
49 -Click **Add Preset**, give it a name, choose models, then click **Save Presets**.
50 -
51 -![Add a model preset](../res/usage/webui/model-presets-add.png)
66 +Click **Add**, give it a name, choose models, then click **Save**.
67
68 Good preset names are easy to spot quickly:
69
@@ -76,11 +91,22 @@ If you are not sure what to create, start with three presets:
91
92 You can always rename them later.
93
94 +## Choose Defaults For New Chats
95 +
96 +In **Settings → Agent → Models**, choose the global preset used by new chats.
97 +The summary shows its main, utility, and embedding models. Changing it also
98 +applies that preset to the currently open chat.
99 +
100 +Use **Per-project / agent** to open the full Model Configuration plugin. Its
101 +scope selector lets you choose a different default preset for a project, an
102 +agent profile, or their combination. These scopes store only the preset choice;
103 +editing a preset updates every scope that uses it.
104 +
105 ## How Presets Fit With Other Controls
106
107 | Control | What it changes |
108 | --- | --- |
83 -| **Model Preset** | Which models power the chat. |
109 +| **Model Preset** | Which main, utility, and embedding models power the chat. |
110 | **Agent Profile** | The agent's role, tone, and prompt behavior. |
111 | **Project** | Workspace, files, memory, secrets, and project instructions. |
112 | **Skill** | A specific procedure added to prompt protocol. |
extensions/python/message_loop_prompts_after/_70_include_agent_info.py
+5 -8
@@ -8,15 +8,12 @@ class IncludeAgentInfo(Extension):
8 return
9
10 # read prompt
11 - from plugins._model_config.helpers.model_config import get_chat_model_config, is_chat_override_allowed
11 + from plugins._model_config.helpers.model_config import (
12 + get_chat_model_config,
13 + get_effective_preset_name,
14 + )
15 chat_cfg = get_chat_model_config(self.agent)
13 -
14 - # detect active preset (only when override is allowed)
15 - preset_name = ""
16 - if is_chat_override_allowed(self.agent):
17 - override = self.agent.context.get_data("chat_model_override")
18 - if isinstance(override, dict):
19 - preset_name = override.get("preset_name", "")
16 + preset_name = get_effective_preset_name(self.agent)
17
18 agent_info_prompt = self.agent.read_prompt(
19 "agent.extras.agent_info.md",
helpers/integration_commands.py
+9 -16
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
15
16
17 _CLEAR_VALUES = {"", "default", "none", "clear", "off"}
18 +_MODEL_INHERIT_VALUES = {"", "none", "clear", "off", "inherit"}
19
20
21 @dataclass(frozen=True)
@@ -85,7 +86,7 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = (
86 "Show or switch the chat model preset.",
87 "Configuration",
88 aliases=("config", "preset"),
88 - args_hint="[preset|default]",
89 + args_hint="[preset|inherit]",
90 ),
91 IntegrationCommandDef(
92 "agent",
@@ -270,7 +271,6 @@ def _handle_queue(context: "AgentContext", args: str) -> str:
271
272 def _handle_status(context: "AgentContext") -> str:
273 project_name = context.get_data("project") or "none"
273 - override = context.get_data("chat_model_override")
274 agent_profile = getattr(context.agent0.config, "profile", "default")
275 running = "running" if context.is_running() else "idle"
276 if getattr(context, "paused", False):
@@ -279,7 +279,7 @@ def _handle_status(context: "AgentContext") -> str:
279 return (
280 f"Status: {running}\n"
281 f"Project: {project_name}\n"
282 - f"Model: {_describe_override(override)}\n"
282 + f"Model: {model_config.get_effective_preset_name(context.agent0)}\n"
283 f"Agent: {agent_profile}\n"
284 f"Queued messages: {queue_count}"
285 )
@@ -394,9 +394,9 @@ def _handle_model(context: "AgentContext", args: str) -> str:
394 current_override = context.get_data("chat_model_override")
395
396 if not args:
397 - current_label = _describe_override(current_override)
397 + current_label = model_config.get_effective_preset_name(context.agent0)
398 available = ", ".join(preset["name"] for preset in presets) or "none"
399 - suffix = "Use /model <name> to switch, or /model default to clear it."
399 + suffix = "Use /model <name> to switch, or /model inherit to use the scoped preset."
400 if not allowed:
401 suffix = "Per-chat config switching is disabled in Model Configuration."
402 return (
@@ -409,13 +409,14 @@ def _handle_model(context: "AgentContext", args: str) -> str:
409 return "Config switching is disabled in Model Configuration."
410
411 desired = _strip_quotes(args)
412 - if _normalize_lookup(desired) in _CLEAR_VALUES:
412 + if _normalize_lookup(desired) in _MODEL_INHERIT_VALUES:
413 if not current_override:
414 - return "Already using the default config."
414 + return "Already using the scoped model preset."
415 context.set_data("chat_model_override", None)
416 save_tmp_chat(context)
417 mark_dirty_for_context(context.id, reason="integration_commands.config_clear")
418 - return "Switched back to the default config."
418 + inherited = model_config.get_effective_preset_name(context.agent0)
419 + return f"Switched back to the scoped model preset: {inherited}."
420
421 match, ambiguous = _match_named_item(presets, desired, keys=("name",))
422 if ambiguous:
@@ -503,14 +504,6 @@ def _describe_project(items: list[dict], current_name: str) -> str:
504 return current_name
505
506
506 -def _describe_override(override: dict | None) -> str:
507 - if not override:
508 - return "Default"
509 - if isinstance(override, dict) and override.get("preset_name"):
510 - return str(override["preset_name"])
511 - return "Custom override"
512 -
513 -
507 def _strip_quotes(value: str) -> str:
508 trimmed = value.strip()
509 if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in {'"', "'"}:
helpers/integration_commands.py.dox.md
+6 -16
@@ -3,27 +3,16 @@
3 ## Purpose
4
5 - Own the `integration_commands.py` helper module.
6 -- This module parses external integration slash commands such as project/config/send controls.
6 +- This module parses external integration slash commands such as project/model/send controls.
7 - Keep this file-level DOX profile synchronized with `integration_commands.py` because this directory is intentionally flat.
8
9 ## Ownership
10
11 - `integration_commands.py` owns the runtime implementation.
12 - `integration_commands.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 -- Top-level functions:
14 -- `extract_command_line(text: str) -> str`
15 -- `parse_command(text: str) -> tuple[str, str] | None`
16 -- `try_handle_command(context: 'AgentContext', text: str) -> str | None`
17 -- `_handle_queue(context: 'AgentContext', args: str) -> str`
18 -- `_handle_project(context: 'AgentContext', args: str) -> str`
19 -- `_handle_config(context: 'AgentContext', args: str) -> str`
20 -- `_format_project_entry(item: dict) -> str`
21 -- `_describe_project(items: list[dict], current_name: str) -> str`
22 -- `_describe_override(override: dict | None) -> str`
23 -- `_strip_quotes(value: str) -> str`
24 -- `_normalize_lookup(value: str) -> str`
25 -- `_match_named_item(items: list[dict], desired: str, keys: tuple[str, ...]) -> tuple[dict | None, list[dict]]`
26 -- Notable constants/configuration names: `_CLEAR_VALUES`, `_SUPPORTED_COMMANDS`.
13 +- Public command helpers are `extract_command_line`, `parse_command`, `resolve_command`, `telegram_menu_commands`, `command_names`, `help_text`, `unknown_command_text`, and `try_handle_command`.
14 +- Internal handlers own queue, status, session, lifecycle, toggle, project, model-preset, and agent-profile commands.
15 +- Notable constants are `COMMAND_REGISTRY`, `_CLEAR_VALUES`, and `_MODEL_INHERIT_VALUES`.
16
17 ## Runtime Contracts
18
@@ -32,10 +21,11 @@
21 - Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence.
22 - Imported dependency areas include: `__future__`, `helpers`, `helpers.persist_chat`, `helpers.state_monitor_integration`, `plugins._model_config.helpers`, `re`, `typing`.
23 - `/agent` switches the top-level chat profile and preserves existing subordinate agent profiles.
24 +- `/model <preset>` stores a per-chat global preset reference, `/model inherit` clears it, and status always reports the effective scoped-or-chat preset. `Default` is a real selectable preset, not an alias for clearing the chat selection.
25
26 ## Key Concepts
27
38 -- Important called helpers/classes observed in the source: `splitlines`, `extract_command_line`, `line.partition`, `command.strip.lower`, `parse_command`, `mq.get_queue`, `args.strip.lower`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `_strip_quotes`, `_match_named_item`, `projects.activate_project`, `initialize_agent`, `model_config.is_chat_override_allowed`, `context.get_data`, `context.set_data`, `save_tmp_chat`, `str.strip`, `value.strip`, `value.lower.strip`, `re.sub`.
28 +- Important called helpers/classes include `mq.get_queue`, `mq.send_all_aggregated`, `mark_dirty_for_context`, `projects.activate_project`, `initialize_agent`, `model_config.get_effective_preset_name`, `model_config.get_presets`, `context.get_data`, `context.set_data`, and `save_tmp_chat`.
29 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
30
31 ## Work Guidance
plugins/_a0_connector/AGENTS.md
+1
@@ -61,6 +61,7 @@
61 `connector_file_op_result` frames; resolve the pending file operation only
62 after all chunks for the `op_id` are assembled.
63 - Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible.
64 +- Model preset definitions exposed through v1 are global; project arguments select scope but never create project-owned definitions. Model switcher state reports the effective main, utility, and embedding models and preserves embedding-change notifications.
65
66 ## Work Guidance
67
plugins/_a0_connector/api/v1/model_presets.py
+56 -18
@@ -8,6 +8,12 @@ import plugins._a0_connector.api.v1.base as connector_base
8 class ModelPresets(connector_base.ProtectedConnectorApiHandler):
9 async def process(self, input: dict, request: Request) -> dict | Response:
10 from plugins._model_config.helpers import model_config
11 + from plugins._model_config.api.model_presets import (
12 + _embedding_signature,
13 + _notify_embedding_changed,
14 + _rename_preset_references,
15 + _retired_preset_references,
16 + )
17
18 action = str(input.get("action", "get")).strip() or "get"
19 project_name = str(input.get("project_name") or "").strip()
@@ -15,18 +21,13 @@ class ModelPresets(connector_base.ProtectedConnectorApiHandler):
21
22 if action == "get":
23 if scope == "project":
18 - presets = model_config.get_project_presets(project_name) if project_name else []
19 - return {"ok": True, "presets": presets}
24 + return {"ok": True, "presets": []}
25 if scope == "combined" or project_name:
26 return {
27 "ok": True,
28 "presets": model_config.get_combined_presets(project_name or None),
29 "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 + "project_presets": [],
31 }
32 return {"ok": True, "presets": model_config.get_presets()}
33
@@ -34,16 +35,54 @@ class ModelPresets(connector_base.ProtectedConnectorApiHandler):
35 presets = input.get("presets")
36 if not isinstance(presets, list):
37 return Response(status=400, response="presets must be an array")
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)}
38 + if scope == "project" or project_name:
39 + return Response(
40 + status=400,
41 + response="Preset definitions are global; select a global preset for this project.",
42 + )
43 + previous = model_config.get_presets()
44 + previous_names = {str(preset.get("name") or "") for preset in previous}
45 + previous_embeddings = {
46 + str(preset.get("name") or ""): _embedding_signature(preset)
47 + for preset in previous
48 + }
49 + try:
50 + model_config.save_presets(presets)
51 + except ValueError as exc:
52 + return Response(status=400, response=str(exc))
53 + saved = model_config.get_presets()
54 + saved_names = {str(preset.get("name") or "") for preset in saved}
55 + retired = _retired_preset_references(previous_names, saved_names)
56 + renames = input.get("renames") if isinstance(input.get("renames"), list) else []
57 + _rename_preset_references([*retired, *renames])
58 + saved_embeddings = {
59 + str(preset.get("name") or ""): _embedding_signature(preset)
60 + for preset in saved
61 + }
62 + if previous_embeddings != saved_embeddings:
63 + _notify_embedding_changed()
64 + return {"ok": True, "presets": saved}
65
66 if action == "reset":
44 - presets = model_config.reset_presets(
45 - project_name=project_name if scope == "project" or project_name else None
67 + if scope == "project" or project_name:
68 + return Response(status=400, response="Project presets cannot be reset.")
69 + previous = model_config.get_presets()
70 + previous_embeddings = {
71 + str(preset.get("name") or ""): _embedding_signature(preset)
72 + for preset in previous
73 + }
74 + presets = model_config.reset_presets()
75 + saved_names = {str(preset.get("name") or "") for preset in presets}
76 + previous_names = {str(preset.get("name") or "") for preset in previous}
77 + _rename_preset_references(
78 + _retired_preset_references(previous_names, saved_names)
79 )
80 + current_embeddings = {
81 + str(preset.get("name") or ""): _embedding_signature(preset)
82 + for preset in presets
83 + }
84 + if previous_embeddings != current_embeddings:
85 + _notify_embedding_changed()
86 return {"ok": True, "presets": presets}
87
88 if action == "resolve":
@@ -52,8 +91,7 @@ class ModelPresets(connector_base.ProtectedConnectorApiHandler):
91 return Response(status=400, response="Missing preset name")
92 resolved = model_config.resolve_preset(
93 name,
55 - scope=scope or "global",
56 - project_name=project_name or None,
94 + scope="global",
95 )
96 if not resolved:
97 return Response(status=404, response=f"Preset '{name}' not found")
@@ -61,8 +99,8 @@ class ModelPresets(connector_base.ProtectedConnectorApiHandler):
99 "ok": True,
100 "preset": {
101 **resolved,
64 - "scope": scope or "global",
65 - "project_name": project_name if scope == "project" else "",
102 + "scope": "global",
103 + "project_name": "",
104 },
105 }
106
plugins/_a0_connector/api/v1/model_switcher.py
+37 -2
@@ -4,7 +4,9 @@ from __future__ import annotations
4 import time
5 from typing import Callable
6
7 +from helpers import defer
8 from helpers.api import Request, Response
9 +from helpers.extension import call_extensions_async
10 import plugins._a0_connector.api.v1.base as connector_base
11
12 _MODEL_OVERRIDE_REVISION_KEY = "_model_config_override_revision"
@@ -91,6 +93,11 @@ def _notify_model_override_changed(context: object, context_id: str) -> None:
93 pass
94
95
96 +def _notify_embedding_if_changed(before: dict, after: dict) -> None:
97 + if before != after:
98 + defer.DeferredTask().start_task(call_extensions_async, "embedding_model_changed")
99 +
100 +
101 class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
102 async def process(self, input: dict, request: Request) -> dict | Response:
103 from agent import AgentContext
@@ -113,6 +120,7 @@ class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
120 chat_providers = []
121 chat_model = model_config.get_chat_model_config(agent)
122 utility_model = model_config.get_utility_model_config(agent)
123 + embedding_model = model_config.get_embedding_model_config(agent)
124
125 def _has_api_key(config: object) -> bool:
126 if not isinstance(config, dict):
@@ -130,10 +138,16 @@ class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
138 "ok": True,
139 "allowed": bool(model_config.is_chat_override_allowed(agent)),
140 "override": override,
141 + "configured_preset": model_config.get_configured_preset_name(agent=agent),
142 + "effective_preset": model_config.get_effective_preset_name(agent),
143 "presets": model_config.get_presets(),
144 "chat_providers": chat_providers,
145 "main_model": _model_payload(chat_model, has_api_key=_has_api_key(chat_model)),
146 "utility_model": _model_payload(utility_model, has_api_key=_has_api_key(utility_model)),
147 + "embedding_model": _model_payload(
148 + embedding_model,
149 + has_api_key=_has_api_key(embedding_model),
150 + ),
151 }
152
153 if action == "get":
@@ -155,31 +169,52 @@ class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
169 preset = model_config.get_preset_by_name(preset_name)
170 if not preset:
171 return Response(status=404, response=f"Preset '{preset_name}' not found")
158 - context.set_data("chat_model_override", {"preset_name": preset_name})
172 + previous_embedding = model_config.get_embedding_model_config(agent)
173 + context.set_data(
174 + "chat_model_override",
175 + {"preset_name": str(preset.get("name") or preset_name)},
176 + )
177 save_tmp_chat(context)
178 _notify_model_override_changed(context, context_id)
179 + _notify_embedding_if_changed(
180 + previous_embedding,
181 + model_config.get_embedding_model_config(agent),
182 + )
183 return build_state()
184
185 if action == "clear":
186 + previous_embedding = model_config.get_embedding_model_config(agent)
187 context.set_data("chat_model_override", None)
188 save_tmp_chat(context)
189 _notify_model_override_changed(context, context_id)
190 + _notify_embedding_if_changed(
191 + previous_embedding,
192 + model_config.get_embedding_model_config(agent),
193 + )
194 return build_state()
195
196 if action == "set_override":
197 main_model = _coerce_override_model(input.get("main_model"))
198 utility_model = _coerce_override_model(input.get("utility_model"))
172 - if not main_model and not utility_model:
199 + embedding_model = _coerce_override_model(input.get("embedding_model"))
200 + if not main_model and not utility_model and not embedding_model:
201 return Response(status=400, response="Missing model override payload")
202
203 + previous_embedding = model_config.get_embedding_model_config(agent)
204 override: dict[str, dict[str, str]] = {}
205 if main_model:
206 override["chat"] = main_model
207 if utility_model:
208 override["utility"] = utility_model
209 + if embedding_model:
210 + override["embedding"] = embedding_model
211 context.set_data("chat_model_override", override)
212 save_tmp_chat(context)
213 _notify_model_override_changed(context, context_id)
214 + _notify_embedding_if_changed(
215 + previous_embedding,
216 + model_config.get_embedding_model_config(agent),
217 + )
218 return build_state()
219
220 return Response(status=400, response=f"Unknown action: {action}")
plugins/_browser/AGENTS.md
+1
@@ -30,6 +30,7 @@
30 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
31 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
32 - Do not hardcode user-specific browser paths or secrets.
33 +- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection.
34
35 ## Work Guidance
36
plugins/_browser/helpers/config.py
+9 -1
@@ -249,9 +249,17 @@ def resolve_browser_model_selection(
249 preset = model_config.get_preset_by_name(preset_name)
250 if isinstance(preset, dict):
251 if hasattr(model_config, "build_config_from_preset"):
252 + default_preset = model_config.get_preset_by_name(
253 + model_config.DEFAULT_PRESET_NAME
254 + ) or {}
255 + base_config = (
256 + model_config.preset_to_config(default_preset)
257 + if hasattr(model_config, "preset_to_config")
258 + else {}
259 + )
260 preset_config = model_config.build_config_from_preset(
261 preset,
254 - model_config.get_config(agent) if hasattr(model_config, "get_config") else {},
262 + base_config,
263 strip_api_key=False,
264 slots=("chat",),
265 )
plugins/_model_config/AGENTS.md
+12 -4
@@ -2,27 +2,35 @@
2
3 ## Purpose
4
5 -- Own LLM model selection, presets, API-key checks, scoped overrides, and model settings UI.
5 +- Own global LLM preset definitions, scoped preset selection, API-key checks, chat overrides, migration, and model settings UI.
6
7 ## Ownership
8
9 - `helpers/model_config.py` owns config resolution, presets, overrides, and runtime model object construction.
10 - `api/` owns model config, override, preset, search, and API-key endpoints.
11 - `webui/` owns model settings, summaries, switcher, and API-key UI.
12 -- `default_config.yaml`, `default_presets.yaml`, `provider_metadata.yaml`, `hooks.py`, and `plugin.yaml` own defaults, metadata, hooks, and manifest.
12 +- `extensions/python/startup_migration/` owns conversion from legacy full configs and project presets, followed by first-launch preset initialization.
13 +- `default_config.yaml`, `mode_presets_fallback.yaml`, `provider_metadata.yaml`, `hooks.py`, and `plugin.yaml` own plugin defaults, offline presets, metadata, hooks, and manifest.
14
15 ## Local Contracts
16
16 -- Preserve global, project, agent, and chat override resolution order.
17 +- `Default` is the first global preset and cannot be deleted or renamed. It owns the complete main, utility, and embedding baseline.
18 +- Preset definitions are global. Global, project, agent-profile, and project/profile plugin configs persist only `model_preset`; chats may persist a preset reference as their explicit override.
19 +- Preserve scoped plugin resolution order and fall back invalid or missing scope/chat references to `Default`.
20 - Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules.
21 - Keep provider metadata and API-key checks safe around secrets.
22 - Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
23 - `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
21 -- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
24 +- Non-default presets may inherit omitted slots or durable tuning from `Default`, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
25 - Changing a model provider in the settings UI must clear `api_base` and `kwargs` because both may be provider-specific.
26 - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
27 - `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
28 +- The preset editor maps each model provider's API-key field to the shared API-key store; saving the editor persists dirty keys separately and never writes secrets into preset YAML.
29 +- The compact chat selector label combines the effective preset with only the leaf name of its main model; utility and provider text stay out of the closed selector.
30 - Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults.
31 +- Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`.
32 +- Migration must preserve existing definitions and distinct scoped model choices, back up replaced user files once, strip inline secrets, and remain idempotent.
33 +- On every startup after migration, short-circuit when `usr/plugins/_model_config/presets.yaml` exists. Only a missing collection may fetch `agent0ai/a0-presets`; parse remote and plugin-local fallback YAML through the same validator, strip secrets before persistence, and persist `mode_presets_fallback.yaml` when download or validation fails.
34 - Model-name catalogs open below the input from either a field click or the embedded magnifier.
35
36 ## Work Guidance
plugins/_model_config/README.md
+38 -66
@@ -1,86 +1,58 @@
1 # Model Configuration
2
3 -Manage which models Agent Zero uses for chat, utility, and embeddings, with support for scoped overrides and reusable presets.
3 +Manage the reusable model presets used for Agent Zero's main, utility, and embedding models.
4
5 -## What It Does
5 +## Model Presets
6
7 -This plugin centralizes model selection and model-related settings for the application. It provides helpers and APIs for:
7 +- `Default` is always present, appears first, and cannot be renamed or deleted.
8 +- Every runtime configuration resolves from a preset. There is no separate durable model configuration layer.
9 +- Preset definitions are global and live in `usr/plugins/_model_config/presets.yaml`. Plugin defaults in `mode_presets_fallback.yaml` are used only when a saved collection cannot be initialized.
10 +- Non-default presets may omit advanced fields or model slots; omitted values resolve from `Default`, while provider-specific `kwargs` are replaced or cleared instead of leaking between providers.
11 +- API keys remain in the approved environment/settings flow and are never written into preset YAML.
12
9 -- selecting chat, utility, and embedding models
10 -- reading and saving model presets
11 -- checking for missing API keys
12 -- allowing optional per-chat model overrides
13 -- resolving config at global, project, agent, and chat scope
13 +## Scoped Selection
14
15 -## Main Behavior
15 +Global, project, project/profile, and agent-profile scopes store only a preset name in their standard plugin `config.json`:
16
17 -- **Scoped configuration**
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**
25 - - Builds `ModelConfig` objects and the runtime chat, utility, and embedding wrappers used elsewhere in the app.
26 -- **API key validation**
27 - - Reports configured providers that still require API keys.
17 +```json
18 +{"model_preset": "Balance"}
19 +```
20
29 -## Key Files
21 +For example, a project selection is stored at:
22
31 -- **Core helper**
32 - - `helpers/model_config.py` resolves config, presets, overrides, and runtime model objects.
33 -- **APIs**
34 - - `api/model_config_get.py`
35 - - `api/model_config_set.py`
36 - - `api/model_override.py`
37 - - `api/model_presets.py`
38 - - `api/model_search.py`
39 - - `api/api_keys.py`
40 -- **Hooks**
41 - - `hooks.py` exposes plugin-level integration hooks.
23 +```text
24 +/a0/usr/projects/<project>/.a0proj/plugins/_model_config/config.json
25 +```
26
43 -## Configuration Scope
27 +The normal plugin resolution order selects the most specific available reference. A missing or deleted reference safely resolves to `Default`. Chats may store an explicit preset reference in `chat_model_override`; clearing it returns the chat to its scoped selection.
28
45 -- **Settings section**: `agent`
46 -- **Per-project config**: `true`
47 -- **Per-agent config**: `true`
48 -- **Always enabled**: `true`
29 +## User Interfaces
30
50 -## Project-Scoped Model Config
31 +- Agent Settings shows the global selection, its three resolved models, and actions for preset editing, API keys, and per-project/agent settings.
32 +- The full plugin settings modal uses the generic scope selector and stores only the chosen preset at that scope.
33 +- The closed chat switcher shows the effective preset plus the main model's short name; its menu supports a chat-only selection or returning to the scoped preset.
34 +- The preset editor exposes the shared API key for each selected provider and saves key changes separately from secret-free preset definitions.
35 +- Project Settings selects from the same global preset definitions.
36
52 -Projects store copied model settings in the standard scoped plugin path:
37 +## Initial Presets And Migration
38
54 -```text
55 -/a0/usr/projects/<project>/.a0proj/plugins/_model_config/config.json
56 -```
57 -
58 -Project-only presets live beside that config:
39 +The startup migration converts the prior global full model configuration into `Default`, preserves existing global presets, promotes distinct scoped full configurations and legacy project presets into uniquely named global presets, and rewrites scoped config files to selection-only JSON. Original files receive a `.pre-unified-presets.bak` backup before replacement; the migration is idempotent.
40
60 -```text
61 -/a0/usr/projects/<project>/.a0proj/plugins/_model_config/presets.yaml
62 -```
41 +At every startup, legacy migration runs first. If `usr/plugins/_model_config/presets.yaml` then exists, initialization returns immediately without network access. Otherwise the plugin makes one bounded request for [`agent0ai/a0-presets/model_presets.yaml`](https://github.com/agent0ai/a0-presets/blob/main/model_presets.yaml), validates the whole collection, removes secret fields, and saves it locally. A download, parse, or validation failure processes and saves plugin-local `mode_presets_fallback.yaml` through the same validation path, so initialization remains usable offline.
42
64 -The project preset file uses the same plain YAML list schema as global presets. It does not contain scope metadata:
65 -
66 -```yaml
67 -- name: Research
68 - chat:
69 - provider: openrouter
70 - name: anthropic/claude-sonnet-5
71 - api_base: ""
72 - ctx_length: 200000
73 - ctx_history: 0.7
74 - vision: true
75 - utility:
76 - provider: openrouter
77 - name: openai/gpt-5.4-mini
78 -```
43 +## Key Files
44
80 -Preset slots are partial overlays. Missing fields inherit from the current effective config, so a preset can switch only the model identity while preserving tuned context windows and rate limits. Provider-specific `kwargs` are replaced or cleared. The `utility` and `embedding` slots are optional and only apply when they declare a provider or model name; otherwise those configured models are inherited. Selecting a preset for a project writes the merged result into the project's `config.json`.
45 +- `helpers/model_config.py` owns preset validation, resolution, compatibility config shapes, and runtime model construction.
46 +- `api/model_presets.py` owns global preset editing, scoped selection, and reference repair after rename/delete/reset.
47 +- `extensions/python/startup_migration/_10_migrate_model_config.py` owns legacy conversion.
48 +- `extensions/python/startup_migration/_20_bootstrap_model_presets.py` owns missing-collection initialization and plugin-local fallback.
49 +- `webui/preset-overview.html` is the shared Settings/plugin-settings summary widget.
50 +- `webui/main.html` is the preset editor.
51
52 ## Plugin Metadata
53
84 -- **Name**: `_model_config`
85 -- **Title**: `Model Configuration`
86 -- **Description**: Manages LLM model selection and configuration for chat, utility, and embedding models. Supports per-project and per-agent overrides with optional per-chat model switching.
54 +- Name: `_model_config`
55 +- Settings section: `agent`
56 +- Per-project config: `true`
57 +- Per-agent config: `true`
58 +- Always enabled: `true`
plugins/_model_config/api/model_config_get.py
+24 -9
@@ -1,7 +1,7 @@
1 from helpers.api import ApiHandler, Request, Response
2 -from helpers import plugins
2 from helpers.providers import get_raw_providers
3 from plugins._model_config.helpers import model_config
4 +from agent import AgentContext
5 import models
6
7
@@ -9,15 +9,27 @@ class ModelConfigGet(ApiHandler):
9 async def process(self, input: dict, request: Request) -> dict | Response:
10 project_name = input.get("project_name", "")
11 agent_profile = input.get("agent_profile", "")
12 + context_id = str(input.get("context_id") or "").strip()
13 + context = AgentContext.get(context_id) if context_id else None
14 + agent = context.agent0 if context else None
15
13 - config = model_config.get_config(
14 - project_name=project_name or None,
15 - agent_profile=agent_profile or None,
16 - )
17 -
18 - # Provide default if no config found
19 - if not config:
20 - config = plugins.get_default_plugin_config("_model_config") or {}
16 + if agent:
17 + config = model_config.get_effective_config(agent)
18 + configured_preset = model_config.get_configured_preset_name(agent=agent)
19 + selected_preset = model_config.get_effective_preset_name(agent)
20 + else:
21 + config = model_config.get_config(
22 + project_name=project_name or None,
23 + agent_profile=agent_profile or None,
24 + )
25 + configured_preset = model_config.get_configured_preset_name(
26 + project_name=project_name or None,
27 + agent_profile=agent_profile or None,
28 + )
29 + selected_preset = str(
30 + config.get(model_config.MODEL_PRESET_CONFIG_KEY)
31 + or model_config.DEFAULT_PRESET_NAME
32 + )
33
34 # Add provider lists for UI dropdowns
35 chat_providers = model_config.get_chat_providers()
@@ -51,4 +63,7 @@ class ModelConfigGet(ApiHandler):
63 "model_configured_label": " / ".join(
64 part for part in (chat_provider, chat_name) if part
65 ),
66 + "presets": model_config.get_presets(),
67 + "configured_preset": configured_preset,
68 + "selected_preset": selected_preset,
69 }
plugins/_model_config/api/model_config_set.py
+33 -11
@@ -1,8 +1,9 @@
1 from copy import deepcopy
2
3 from helpers.api import ApiHandler, Request, Response
4 -from helpers import plugins, defer, dotenv
4 +from helpers import defer, dotenv
5 from helpers.extension import call_extensions_async
6 +from plugins._model_config.helpers import model_config
7
8 API_KEY_PLACEHOLDER = "************"
9
@@ -32,18 +33,39 @@ class ModelConfigSet(ApiHandler):
33 dotenv.save_dotenv_value(f"API_KEY_{provider.upper()}", api_key)
34 section.pop("api_key", None)
35
35 - # Read previous config BEFORE saving so we can detect changes
36 - prev_config = plugins.get_plugin_config(
37 - "_model_config",
38 - project_name=project_name or None,
39 - agent_profile=agent_profile or None,
40 - ) or {}
36 + preset_name = str(
37 + input.get("preset_name")
38 + or config_to_save.get(model_config.MODEL_PRESET_CONFIG_KEY)
39 + or model_config.get_configured_preset_name(
40 + project_name=project_name or None,
41 + agent_profile=agent_profile or None,
42 + )
43 + ).strip()
44 + preset = model_config.resolve_preset(preset_name)
45 + if not preset:
46 + return Response(status=404, response=f"Preset '{preset_name}' not found")
47 + preset_name = str(preset.get("name") or model_config.DEFAULT_PRESET_NAME)
48 +
49 + # Read the preset before saving so embedding changes can still trigger
50 + # the established re-index notification.
51 + prev_config = model_config.resolve_config_settings(
52 + {model_config.MODEL_PRESET_CONFIG_KEY: preset_name}
53 + )
54 +
55 + try:
56 + model_config.update_preset_from_config(preset_name, config_to_save)
57 + except ValueError as exc:
58 + return Response(status=400, response=str(exc))
59 +
60 + # Keep the requested scope pointed at the preset being edited. This is
61 + # selection-only persistence; model dictionaries live in presets.yaml.
62 + from helpers import plugins
63
64 plugins.save_plugin_config(
65 "_model_config",
44 - project_name=project_name,
45 - agent_profile=agent_profile,
46 - settings=config_to_save,
66 + project_name=project_name or None,
67 + agent_profile=agent_profile or None,
68 + settings={model_config.MODEL_PRESET_CONFIG_KEY: preset_name},
69 )
70
71 # Check if embedding model changed and notify
@@ -58,4 +80,4 @@ class ModelConfigSet(ApiHandler):
80 call_extensions_async, "embedding_model_changed"
81 )
82
61 - return {"ok": True}
83 + return {"ok": True, "preset_name": preset_name}
plugins/_model_config/api/model_override.py
+36 -4
@@ -1,6 +1,8 @@
1 import time
2
3 from helpers.api import ApiHandler, Request, Response
4 +from helpers import defer
5 +from helpers.extension import call_extensions_async
6 from helpers.persist_chat import save_tmp_chat
7 from agent import AgentContext
8 from plugins._model_config.helpers import model_config
@@ -19,6 +21,11 @@ def _notify_model_override_changed(ctx: AgentContext) -> None:
21 pass
22
23
24 +def _notify_embedding_if_changed(before: dict, after: dict) -> None:
25 + if before != after:
26 + defer.DeferredTask().start_task(call_extensions_async, "embedding_model_changed")
27 +
28 +
29 class ModelOverride(ApiHandler):
30 async def process(self, input: dict, request: Request) -> dict | Response:
31 context_id = input.get("context_id", "")
@@ -34,7 +41,12 @@ class ModelOverride(ApiHandler):
41 if action == "get":
42 override = ctx.get_data("chat_model_override")
43 allowed = model_config.is_chat_override_allowed(ctx.agent0)
37 - return {"override": override, "allowed": allowed}
44 + return {
45 + "override": override,
46 + "allowed": allowed,
47 + "configured_preset": model_config.get_configured_preset_name(agent=ctx.agent0),
48 + "effective_preset": model_config.get_effective_preset_name(ctx.agent0),
49 + }
50
51 elif action == "set":
52 if not model_config.is_chat_override_allowed(ctx.agent0):
@@ -42,9 +54,14 @@ class ModelOverride(ApiHandler):
54 override_config = input.get("override")
55 if not override_config or not isinstance(override_config, dict):
56 return Response(status=400, response="Missing or invalid override config")
57 + previous_embedding = model_config.get_embedding_model_config(ctx.agent0)
58 ctx.set_data("chat_model_override", override_config)
59 save_tmp_chat(ctx)
60 _notify_model_override_changed(ctx)
61 + _notify_embedding_if_changed(
62 + previous_embedding,
63 + model_config.get_embedding_model_config(ctx.agent0),
64 + )
65 return {"ok": True, "override": override_config}
66
67 elif action == "set_preset":
@@ -53,21 +70,36 @@ class ModelOverride(ApiHandler):
70 preset_name = input.get("preset_name", "")
71 if not preset_name:
72 return Response(status=400, response="Missing preset_name")
73 + previous_embedding = model_config.get_embedding_model_config(ctx.agent0)
74 # Verify preset exists
75 preset = model_config.get_preset_by_name(preset_name)
76 if not preset:
77 return Response(status=404, response=f"Preset '{preset_name}' not found")
78 # Store as a preset reference
61 - override_value = {"preset_name": preset_name}
79 + canonical_name = str(preset.get("name") or preset_name)
80 + override_value = {"preset_name": canonical_name}
81 ctx.set_data("chat_model_override", override_value)
82 save_tmp_chat(ctx)
83 _notify_model_override_changed(ctx)
65 - return {"ok": True, "preset_name": preset_name}
84 + _notify_embedding_if_changed(
85 + previous_embedding,
86 + model_config.get_embedding_model_config(ctx.agent0),
87 + )
88 + return {"ok": True, "preset_name": canonical_name}
89
90 elif action == "clear":
91 + previous_embedding = model_config.get_embedding_model_config(ctx.agent0)
92 ctx.set_data("chat_model_override", None)
93 save_tmp_chat(ctx)
94 _notify_model_override_changed(ctx)
71 - return {"ok": True, "override": None}
95 + _notify_embedding_if_changed(
96 + previous_embedding,
97 + model_config.get_embedding_model_config(ctx.agent0),
98 + )
99 + return {
100 + "ok": True,
101 + "override": None,
102 + "effective_preset": model_config.get_configured_preset_name(agent=ctx.agent0),
103 + }
104
105 return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/api/model_presets.py
+227 -31
@@ -1,66 +1,262 @@
1 +import json
2 +
3 from helpers.api import ApiHandler, Request, Response
4 +from helpers import defer, files, plugins
5 +from helpers.extension import call_extensions_async
6 +from helpers.persist_chat import _write_atomic, save_tmp_chat
7 +from agent import AgentContext
8 +from plugins._model_config.api.model_override import _notify_model_override_changed
9 from plugins._model_config.helpers import model_config
10
11
12 +def _rename_preset_references(renames: object) -> None:
13 + if not isinstance(renames, list):
14 + return
15 + mapping = {
16 + str(item.get("from") or "").strip().casefold(): str(item.get("to") or "").strip()
17 + for item in renames
18 + if isinstance(item, dict)
19 + and str(item.get("from") or "").strip()
20 + and str(item.get("to") or "").strip()
21 + }
22 + mapping.pop(model_config.DEFAULT_PRESET_NAME.casefold(), None)
23 + if not mapping:
24 + return
25 +
26 + assets = plugins.find_plugin_assets(
27 + plugins.CONFIG_FILE_NAME,
28 + plugin_name="_model_config",
29 + project_name="*",
30 + agent_profile="*",
31 + only_first=False,
32 + )
33 + for asset in assets:
34 + path = str(asset.get("path") or "")
35 + try:
36 + config = json.loads(files.read_file(path))
37 + except Exception:
38 + continue
39 + if not isinstance(config, dict):
40 + continue
41 + selected = str(config.get(model_config.MODEL_PRESET_CONFIG_KEY) or "").strip()
42 + replacement = mapping.get(selected.casefold())
43 + if replacement:
44 + config[model_config.MODEL_PRESET_CONFIG_KEY] = replacement
45 + files.write_file(path, json.dumps(config))
46 +
47 + # Chats are lazy-loaded, so update durable references as well as live
48 + # AgentContext objects. Otherwise renaming a preset would silently send an
49 + # unopened chat back to Default the next time it is loaded.
50 + loaded_context_ids = {str(context.id) for context in AgentContext.all()}
51 + chat_paths = files.find_existing_paths_by_pattern(
52 + files.get_abs_path("usr", "chats", "*", "chat.json")
53 + )
54 + for path in chat_paths:
55 + chat_id = files.basename(files.dirname(path))
56 + if chat_id in loaded_context_ids:
57 + continue
58 + try:
59 + chat = json.loads(files.read_file(path))
60 + except Exception:
61 + continue
62 + data = chat.get("data") if isinstance(chat, dict) else None
63 + override = data.get("chat_model_override") if isinstance(data, dict) else None
64 + selected = str(override.get("preset_name") or "").strip() if isinstance(override, dict) else ""
65 + replacement = mapping.get(selected.casefold())
66 + if not replacement:
67 + continue
68 + data["chat_model_override"] = {"preset_name": replacement}
69 + _write_atomic(path, json.dumps(chat, ensure_ascii=False))
70 +
71 + for context in AgentContext.all():
72 + override = context.get_data("chat_model_override")
73 + if not isinstance(override, dict):
74 + continue
75 + selected = str(override.get("preset_name") or "").strip()
76 + replacement = mapping.get(selected.casefold())
77 + if not replacement:
78 + continue
79 + context.set_data("chat_model_override", {"preset_name": replacement})
80 + save_tmp_chat(context)
81 + _notify_model_override_changed(context)
82 +
83 +
84 +def _retired_preset_references(
85 + previous_names: set[str],
86 + saved_names: set[str],
87 +) -> list[dict[str, str]]:
88 + saved_by_casefold = {name.casefold(): name for name in saved_names}
89 + return [
90 + {
91 + "from": name,
92 + "to": saved_by_casefold.get(
93 + name.casefold(),
94 + model_config.DEFAULT_PRESET_NAME,
95 + ),
96 + }
97 + for name in previous_names - saved_names
98 + ]
99 +
100 +
101 +def _embedding_signature(preset: dict | None):
102 + if not isinstance(preset, dict):
103 + return {}
104 + default = model_config.resolve_preset(model_config.DEFAULT_PRESET_NAME) or {}
105 + config = model_config.preset_to_config(default)
106 + if str(preset.get("name") or "") != model_config.DEFAULT_PRESET_NAME:
107 + config = model_config.build_config_from_preset(
108 + preset,
109 + config,
110 + strip_api_key=False,
111 + )
112 + embedding = config.get("embedding_model")
113 + return embedding if isinstance(embedding, dict) else {}
114 +
115 +
116 +def _notify_embedding_changed() -> None:
117 + defer.DeferredTask().start_task(call_extensions_async, "embedding_model_changed")
118 +
119 +
120 class ModelPresets(ApiHandler):
121 async def process(self, input: dict, request: Request) -> dict | Response:
122 action = input.get("action", "get")
123 project_name = str(input.get("project_name") or "").strip()
124 + agent_profile = str(input.get("agent_profile") or "").strip()
125 + context_id = str(input.get("context_id") or "").strip()
126 scope = str(input.get("scope") or "").strip()
127
128 if action == "get":
129 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 -
130 + return {"ok": True, "presets": []}
131 presets = model_config.get_presets()
29 - return {"ok": True, "presets": presets}
132 + context = AgentContext.get(context_id) if context_id else None
133 + if context:
134 + configured = model_config.get_configured_preset_name(agent=context.agent0)
135 + selected = model_config.get_effective_preset_name(context.agent0)
136 + else:
137 + configured = model_config.get_configured_preset_name(
138 + project_name=project_name or None,
139 + agent_profile=agent_profile or None,
140 + )
141 + selected = configured
142 + return {
143 + "ok": True,
144 + "presets": presets,
145 + "global_presets": presets,
146 + "project_presets": [],
147 + "configured_preset": configured,
148 + "selected_preset": selected,
149 + }
150
151 elif action == "save":
152 presets = input.get("presets")
153 if not isinstance(presets, list):
154 return Response(status=400, response="presets must be an array")
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)}
155 + if scope == "project" or project_name:
156 + return Response(
157 + status=400,
158 + response="Preset definitions are global; select a global preset for this project.",
159 + )
160 + previous_names = {
161 + str(preset.get("name") or "") for preset in model_config.get_presets()
162 + }
163 + previous_embeddings = {
164 + str(preset.get("name") or ""): _embedding_signature(preset)
165 + for preset in model_config.get_presets()
166 + }
167 + try:
168 + model_config.save_presets(presets)
169 + except ValueError as exc:
170 + return Response(status=400, response=str(exc))
171 + saved_names = {
172 + str(preset.get("name") or "") for preset in model_config.get_presets()
173 + }
174 + retired = _retired_preset_references(previous_names, saved_names)
175 + renames = input.get("renames") if isinstance(input.get("renames"), list) else []
176 + _rename_preset_references([*retired, *renames])
177 + saved_embeddings = {
178 + str(preset.get("name") or ""): _embedding_signature(preset)
179 + for preset in model_config.get_presets()
180 + }
181 + if previous_embeddings != saved_embeddings:
182 + _notify_embedding_changed()
183 + return {"ok": True, "presets": model_config.get_presets()}
184
185 elif action == "reset":
42 - presets = model_config.reset_presets(
43 - project_name=project_name if scope == "project" or project_name else None
44 - )
186 + if scope == "project" or project_name:
187 + return Response(status=400, response="Project presets cannot be reset.")
188 + previous = model_config.get_presets()
189 + previous_embeddings = {
190 + str(preset.get("name") or ""): _embedding_signature(preset)
191 + for preset in previous
192 + }
193 + presets = model_config.reset_presets()
194 + saved_names = {str(preset.get("name") or "") for preset in presets}
195 + previous_names = {str(preset.get("name") or "") for preset in previous}
196 + retired = _retired_preset_references(previous_names, saved_names)
197 + _rename_preset_references(retired)
198 + current_embeddings = {
199 + str(preset.get("name") or ""): _embedding_signature(preset)
200 + for preset in presets
201 + }
202 + if previous_embeddings != current_embeddings:
203 + _notify_embedding_changed()
204 return {"ok": True, "presets": presets}
205
206 + elif action == "select":
207 + name = str(input.get("name") or "").strip()
208 + preset = model_config.resolve_preset(name)
209 + if not preset:
210 + return Response(status=404, response=f"Preset '{name}' not found")
211 + canonical_name = str(preset.get("name") or model_config.DEFAULT_PRESET_NAME)
212 + context = AgentContext.get(context_id) if context_id else None
213 + previous_embedding = (
214 + model_config.get_embedding_model_config(context.agent0)
215 + if context
216 + else None
217 + )
218 + previous_name = (
219 + model_config.get_effective_preset_name(context.agent0)
220 + if context
221 + else model_config.get_configured_preset_name(
222 + project_name=project_name or None,
223 + agent_profile=agent_profile or None,
224 + )
225 + )
226 + previous = model_config.resolve_preset(previous_name)
227 + plugins.save_plugin_config(
228 + "_model_config",
229 + project_name,
230 + agent_profile,
231 + {model_config.MODEL_PRESET_CONFIG_KEY: canonical_name},
232 + )
233 +
234 + if context:
235 + context.set_data("chat_model_override", {"preset_name": canonical_name})
236 + save_tmp_chat(context)
237 + _notify_model_override_changed(context)
238 + current_embedding = (
239 + model_config.get_embedding_model_config(context.agent0)
240 + if context
241 + else _embedding_signature(preset)
242 + )
243 + if (previous_embedding or _embedding_signature(previous)) != current_embedding:
244 + _notify_embedding_changed()
245 + return {"ok": True, "selected_preset": canonical_name}
246 +
247 elif action == "resolve":
248 name = str(input.get("name") or "").strip()
249 if not name:
250 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 - )
251 + resolved = model_config.resolve_preset(name)
252 if not resolved:
253 return Response(status=404, response=f"Preset '{name}' not found")
254 return {
255 "ok": True,
256 "preset": {
257 **resolved,
62 - "scope": scope or "global",
63 - "project_name": project_name if scope == "project" else "",
258 + "scope": "global",
259 + "project_name": "",
260 },
261 }
262
plugins/_model_config/default_config.yaml
+1 -33
@@ -1,33 +1 @@
1 -allow_chat_override: true
2 -
3 -chat_model:
4 - provider: "openrouter"
5 - name: "openai/gpt-5.6-terra"
6 - api_base: ""
7 - ctx_length: 200000
8 - ctx_history: 0.7
9 - vision: true
10 - max_embeds: 10
11 - rl_requests: 0
12 - rl_input: 0
13 - rl_output: 0
14 - kwargs: {}
15 -
16 -utility_model:
17 - provider: "openrouter"
18 - name: "google/gemini-3.1-flash-lite"
19 - api_base: ""
20 - ctx_length: 128000
21 - ctx_input: 0.7
22 - rl_requests: 0
23 - rl_input: 0
24 - rl_output: 0
25 - kwargs: {}
26 -
27 -embedding_model:
28 - provider: "huggingface"
29 - name: "sentence-transformers/all-MiniLM-L6-v2"
30 - api_base: ""
31 - rl_requests: 0
32 - rl_input: 0
33 - kwargs: {}
1 +model_preset: "Default"
plugins/_model_config/default_presets.yaml deleted
-36
@@ -1,36 +0,0 @@
1 -- name: "Max Power"
2 - chat:
3 - provider: "openrouter"
4 - name: "anthropic/claude-opus-4.6"
5 - api_key: ""
6 - api_base: ""
7 - ctx_length: 200000
8 - ctx_history: 0.7
9 - vision: true
10 - utility:
11 - provider: "openrouter"
12 - name: "openai/gpt-5.4-mini"
13 -- name: "Balance"
14 - chat:
15 - provider: "openrouter"
16 - name: "openai/gpt-5.6-terra"
17 - api_key: ""
18 - api_base: ""
19 - ctx_length: 200000
20 - ctx_history: 0.7
21 - vision: true
22 - utility:
23 - provider: "openrouter"
24 - name: "google/gemini-3.1-flash-lite"
25 -- name: "Cost Efficient"
26 - chat:
27 - provider: "openrouter"
28 - name: "moonshotai/kimi-k2.5"
29 - api_key: ""
30 - api_base: ""
31 - ctx_length: 128000
32 - ctx_history: 0.7
33 - vision: false
34 - utility:
35 - provider: "openrouter"
36 - name: "openai/gpt-5.4-nano"
plugins/_model_config/extensions/python/banners/_20_missing_api_key.py
+44 -38
@@ -1,5 +1,4 @@
1 from helpers.extension import Extension
2 -from helpers import plugins
2 from plugins._model_config.helpers import model_config
3
4
@@ -11,7 +10,6 @@ class MissingApiKeyCheck(Extension):
10 ONBOARDING_CTA_TEXT = "Start Onboarding"
11
12 async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
14 - cfg = plugins.get_plugin_config("_model_config") or {}
13 missing_providers = model_config.get_missing_api_key_providers()
14
15 if missing_providers:
@@ -31,40 +29,48 @@ class MissingApiKeyCheck(Extension):
29 })
30
31 # Check preset providers for missing API keys (warning level)
34 - if cfg.get("allow_chat_override"):
35 - preset_missing = []
36 - seen = set()
37 - for preset in cfg.get("model_presets", []):
38 - preset_name = preset.get("name", "Unnamed")
39 - for slot_key, slot_label in [("chat", "Main"), ("utility", "Utility")]:
40 - slot = preset.get(slot_key, {})
41 - provider = slot.get("provider", "")
42 - if not provider:
43 - continue
44 - provider_lower = provider.lower()
45 - if provider_lower in self.LOCAL_PROVIDERS:
46 - continue
47 - # Skip if already covered by default checks
48 - if provider_lower in seen:
49 - continue
50 - # Skip if preset has its own api_key
51 - if slot.get("api_key", "").strip():
52 - continue
53 - if not model_config.has_provider_api_key(provider_lower):
54 - seen.add(provider_lower)
55 - preset_missing.append(f"{preset_name}/{slot_label} ({provider})")
32 + preset_missing = []
33 + seen = set()
34 + for preset in model_config.get_presets():
35 + preset_name = preset.get("name", "Unnamed")
36 + for slot_key, slot_label, model_type in [
37 + ("chat", "Main", "chat"),
38 + ("utility", "Utility", "chat"),
39 + ("embedding", "Embedding", "embedding"),
40 + ]:
41 + slot = preset.get(slot_key, {})
42 + provider = slot.get("provider", "")
43 + if not provider:
44 + continue
45 + provider_lower = provider.lower()
46 + identity = (provider_lower, model_type)
47 + if (
48 + provider_lower in self.LOCAL_PROVIDERS
49 + or model_type == "embedding"
50 + and provider_lower in model_config.LOCAL_EMBEDDING
51 + or identity in seen
52 + ):
53 + continue
54 + if slot.get("api_key", "").strip():
55 + continue
56 + if not model_config.has_provider_api_key(
57 + provider_lower,
58 + model_type=model_type,
59 + ):
60 + seen.add(identity)
61 + preset_missing.append(f"{preset_name}/{slot_label} ({provider})")
62
57 - if preset_missing:
58 - preset_list = ", ".join(preset_missing)
59 - banners.append({
60 - "id": "missing-preset-api-key",
61 - "type": "warning",
62 - "priority": 90,
63 - "title": "Missing API Key for model presets",
64 - "html": f"""No API key configured for preset models: {preset_list}.<br>
65 - These presets will not work until you provide the required API keys.""",
66 - "cta_text": self.ONBOARDING_CTA_TEXT,
67 - "cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}",
68 - "dismissible": True,
69 - "source": "backend"
70 - })
63 + if preset_missing:
64 + preset_list = ", ".join(preset_missing)
65 + banners.append({
66 + "id": "missing-preset-api-key",
67 + "type": "warning",
68 + "priority": 90,
69 + "title": "Missing API Key for model presets",
70 + "html": f"""No API key configured for preset models: {preset_list}.<br>
71 + These presets will not work until you provide the required API keys.""",
72 + "cta_text": self.ONBOARDING_CTA_TEXT,
73 + "cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}",
74 + "dismissible": True,
75 + "source": "backend"
76 + })
plugins/_model_config/extensions/python/startup_migration/_10_migrate_model_config.py
+288 -65
@@ -5,14 +5,11 @@ from copy import deepcopy
5 from helpers.extension import Extension
6 from helpers import files, plugins, yaml as yaml_helper
7 from helpers.print_style import PrintStyle
8 +from plugins._model_config.helpers import model_config
9
10
11 class MigrateModelConfig(Extension):
11 - """
12 - One-time migration: copy legacy model settings into _model_config plugin config.
13 - Runs during initialize_migration. Only migrates if no global plugin config exists yet
14 - and the settings file contains legacy model fields.
15 - """
12 + """Migrate every legacy full model config to unified global presets."""
13
14 LEGACY_FIELDS = [
15 "chat_model_provider", "chat_model_name", "chat_model_api_base",
@@ -31,39 +28,43 @@ class MigrateModelConfig(Extension):
28 ]
29 CONFIG_SECTIONS = ("chat_model", "utility_model", "embedding_model")
30 PRESET_SECTIONS = ("chat", "utility", "embedding")
31 + BACKUP_SUFFIX = ".pre-unified-presets.bak"
32 VENICE_KWARGS = {
33 "a0_api_mode": "chat",
34 "venice_parameters": {"include_venice_system_prompt": False},
35 }
36
37 def execute(self, **kwargs):
40 - self._repair_saved_venice_config()
38 + self._create_global_config_from_legacy_settings()
39 + changed = self._migrate_saved_configs_and_presets()
40 + if changed:
41 + PrintStyle(
42 + background_color="#6734C3",
43 + font_color="white",
44 + padding=True,
45 + ).print("Migrated model configuration to unified model presets.")
46
42 - # Check if global plugin config already exists
47 + def _create_global_config_from_legacy_settings(self) -> None:
48 global_config_path = files.get_abs_path(
44 - files.USER_DIR, files.PLUGINS_DIR, "_model_config", plugins.CONFIG_FILE_NAME
49 + files.USER_DIR,
50 + files.PLUGINS_DIR,
51 + "_model_config",
52 + plugins.CONFIG_FILE_NAME,
53 )
54 if os.path.exists(global_config_path):
47 - return # already migrated or manually configured
55 + return
56
49 - # Read raw settings file to check for legacy model fields
57 settings_file = files.get_abs_path("usr/settings.json")
58 if not os.path.exists(settings_file):
59 return
53 -
60 try:
61 raw = json.loads(files.read_file(settings_file))
62 except Exception:
63 return
58 -
59 - # Check if any legacy model field exists in the raw settings
60 - has_legacy = any(field in raw for field in self.LEGACY_FIELDS)
61 - if not has_legacy:
64 + if not isinstance(raw, dict) or not any(field in raw for field in self.LEGACY_FIELDS):
65 return
66
64 - # Build plugin config from legacy settings
65 - plugin_config = {
66 - "allow_chat_override": False,
67 + config = {
68 "chat_model": {
69 "provider": raw.get("chat_model_provider", "openrouter"),
70 "name": raw.get("chat_model_name", ""),
@@ -89,59 +90,286 @@ class MigrateModelConfig(Extension):
90 },
91 "embedding_model": {
92 "provider": raw.get("embed_model_provider", "huggingface"),
92 - "name": raw.get("embed_model_name", "sentence-transformers/all-MiniLM-L6-v2"),
93 + "name": raw.get(
94 + "embed_model_name",
95 + "sentence-transformers/all-MiniLM-L6-v2",
96 + ),
97 "api_base": raw.get("embed_model_api_base", ""),
98 "rl_requests": raw.get("embed_model_rl_requests", 0),
99 "rl_input": raw.get("embed_model_rl_input", 0),
100 "kwargs": raw.get("embed_model_kwargs", {}),
101 },
102 }
103 + for section in self.CONFIG_SECTIONS:
104 + if not isinstance(config[section].get("kwargs"), dict):
105 + config[section]["kwargs"] = {}
106 + files.write_file(global_config_path, json.dumps(config))
107
100 - # Ensure kwargs are dicts (might be strings from .env format)
101 - for section in ["chat_model", "utility_model", "embedding_model"]:
102 - kw = plugin_config[section].get("kwargs")
103 - if isinstance(kw, str):
104 - plugin_config[section]["kwargs"] = {}
105 -
106 - self._repair_venice_config_slots(plugin_config)
107 -
108 - # Save as global plugin config
109 - plugins.save_plugin_config("_model_config", "", "", plugin_config)
110 - PrintStyle(background_color="#6734C3", font_color="white", padding=True).print(
111 - "Migrated legacy model settings to _model_config plugin config."
108 + def _migrate_saved_configs_and_presets(self) -> bool:
109 + config_assets = plugins.find_plugin_assets(
110 + plugins.CONFIG_FILE_NAME,
111 + plugin_name="_model_config",
112 + project_name="*",
113 + agent_profile="*",
114 + only_first=False,
115 )
113 -
114 - def _repair_saved_venice_config(self):
115 - changed = False
116 - config_path = files.get_abs_path(
117 - files.USER_DIR, files.PLUGINS_DIR, "_model_config", plugins.CONFIG_FILE_NAME
116 + config_assets.sort(
117 + key=lambda item: bool(item.get("project_name") or item.get("agent_profile"))
118 )
119 - presets_path = files.get_abs_path(
120 - files.USER_DIR, files.PLUGINS_DIR, "_model_config", "presets.yaml"
119 +
120 + presets_path = model_config._get_presets_path()
121 + saved_presets = self._read_yaml_list(presets_path)
122 + presets = self._normalize_preset_collection(model_config.get_presets())
123 + changed = (
124 + os.path.exists(presets_path)
125 + and (
126 + saved_presets is None
127 + or saved_presets != model_config.clean_presets_for_file(presets)
128 + )
129 )
130 + for preset in presets:
131 + changed = self._repair_venice_preset(preset) or changed
132
123 - if os.path.exists(config_path):
124 - try:
125 - config = json.loads(files.read_file(config_path))
126 - except Exception:
127 - config = None
128 - if isinstance(config, dict) and self._repair_venice_config_slots(config):
129 - files.write_file(config_path, json.dumps(config))
133 + config_updates: list[tuple[str, str]] = []
134 + for asset in config_assets:
135 + path = str(asset.get("path") or "")
136 + if not path:
137 + continue
138 + raw = self._read_json_dict(path)
139 + if raw is None:
140 + config_updates.append((path, model_config.DEFAULT_PRESET_NAME))
141 changed = True
142 + continue
143
132 - if os.path.exists(presets_path):
144 + label = self._scope_label(asset)
145 + embedded_presets = raw.get("model_presets")
146 + if isinstance(embedded_presets, list):
147 + changed = self._merge_presets(presets, embedded_presets, label) or changed
148 +
149 + if any(section in raw for section in self.CONFIG_SECTIONS):
150 + self._repair_venice_config_slots(raw)
151 + if not asset.get("project_name") and not asset.get("agent_profile"):
152 + replacement = model_config.config_to_preset(
153 + raw,
154 + model_config.DEFAULT_PRESET_NAME,
155 + )
156 + current_default = self._preset_by_name(
157 + presets,
158 + model_config.DEFAULT_PRESET_NAME,
159 + ) or {}
160 + for slot in self.PRESET_SECTIONS:
161 + if not self._slot_has_identity(replacement.get(slot)):
162 + fallback = current_default.get(slot)
163 + if isinstance(fallback, dict):
164 + replacement[slot] = deepcopy(fallback)
165 + self._replace_default(presets, replacement)
166 + selected_name = model_config.DEFAULT_PRESET_NAME
167 + else:
168 + candidate = model_config.config_to_preset(raw, label)
169 + selected_name = self._find_matching_name(presets, candidate)
170 + if not selected_name:
171 + candidate["name"] = self._unique_name(presets, label)
172 + presets.append(candidate)
173 + selected_name = candidate["name"]
174 + config_updates.append((path, selected_name))
175 + changed = True
176 + continue
177 +
178 + selected = str(
179 + raw.get(model_config.MODEL_PRESET_CONFIG_KEY)
180 + or model_config.DEFAULT_PRESET_NAME
181 + ).strip()
182 + resolved = self._preset_by_name(presets, selected)
183 + canonical = (
184 + str(resolved.get("name"))
185 + if resolved
186 + else model_config.DEFAULT_PRESET_NAME
187 + )
188 + normalized = {model_config.MODEL_PRESET_CONFIG_KEY: canonical}
189 + if raw != normalized:
190 + config_updates.append((path, canonical))
191 + changed = True
192 +
193 + retired_project_preset_paths = self._collect_project_presets(presets)
194 + changed = bool(retired_project_preset_paths) or changed
195 + if not changed:
196 + return False
197 +
198 + presets = model_config.validate_presets(
199 + self._normalize_preset_collection(presets)
200 + )
201 + self._backup(presets_path)
202 + files.write_file(presets_path, yaml_helper.dumps(presets))
203 +
204 + for path, selected_name in config_updates:
205 + self._backup(path)
206 + files.write_file(
207 + path,
208 + json.dumps({model_config.MODEL_PRESET_CONFIG_KEY: selected_name}),
209 + )
210 + for path in retired_project_preset_paths:
211 + self._backup(path)
212 + if os.path.exists(path):
213 + os.remove(path)
214 + return True
215 +
216 + def _collect_project_presets(self, presets: list) -> list[str]:
217 + assets = plugins.find_plugin_assets(
218 + model_config.PRESETS_FILE,
219 + plugin_name="_model_config",
220 + project_name="*",
221 + agent_profile="*",
222 + only_first=False,
223 + )
224 + global_path = model_config._get_presets_path()
225 + retired_paths: list[str] = []
226 + for asset in assets:
227 + path = str(asset.get("path") or "")
228 + if not path or path == global_path:
229 + continue
230 try:
134 - presets = yaml_helper.loads(files.read_file(presets_path))
231 + legacy = yaml_helper.loads(files.read_file(path))
232 except Exception:
136 - presets = None
137 - if isinstance(presets, list) and self._repair_venice_presets(presets):
138 - files.write_file(presets_path, yaml_helper.dumps(presets))
139 - changed = True
233 + legacy = None
234 + if isinstance(legacy, list):
235 + self._merge_presets(
236 + presets,
237 + legacy,
238 + self._scope_label(asset),
239 + )
240 + retired_paths.append(path)
241 + return retired_paths
242
141 - if changed:
142 - PrintStyle(background_color="#6734C3", font_color="white", padding=True).print(
143 - "Updated saved Venice model settings for chat completions."
243 + def _merge_presets(self, presets: list, incoming: list, scope_label: str) -> bool:
244 + changed = False
245 + for raw in incoming:
246 + if not isinstance(raw, dict):
247 + continue
248 + candidate = deepcopy(raw)
249 + self._repair_venice_preset(candidate)
250 + requested_name = str(candidate.get("name") or "").strip()
251 + if not requested_name:
252 + continue
253 + candidate["name"] = requested_name
254 + matching = self._find_matching_name(presets, candidate)
255 + if matching:
256 + continue
257 + if self._preset_by_name(presets, requested_name):
258 + candidate["name"] = self._unique_name(
259 + presets,
260 + f"{scope_label} · {requested_name}",
261 + )
262 + presets.append(candidate)
263 + changed = True
264 + return changed
265 +
266 + def _normalize_preset_collection(self, presets: list) -> list:
267 + """Preserve malformed legacy entries by canonicalizing or uniquely renaming them."""
268 + normalized: list[dict] = []
269 + for raw in presets:
270 + if not isinstance(raw, dict):
271 + continue
272 + candidate = model_config._clean_preset_for_file(raw)
273 + name = str(candidate.get("name") or "").strip()
274 + if not name:
275 + continue
276 + candidate["name"] = (
277 + model_config.DEFAULT_PRESET_NAME
278 + if name.casefold() == model_config.DEFAULT_PRESET_NAME.casefold()
279 + else name
280 )
281 + existing = self._preset_by_name(normalized, candidate["name"])
282 + if existing:
283 + if self._preset_signature(existing) == self._preset_signature(candidate):
284 + continue
285 + if candidate["name"] == model_config.DEFAULT_PRESET_NAME:
286 + continue
287 + candidate["name"] = self._unique_name(normalized, candidate["name"])
288 + normalized.append(candidate)
289 + return model_config._ensure_default_preset(normalized)
290 +
291 + def _replace_default(self, presets: list, replacement: dict) -> None:
292 + for index, preset in enumerate(presets):
293 + if str(preset.get("name") or "").casefold() == "default":
294 + presets[index] = replacement
295 + if index:
296 + presets.insert(0, presets.pop(index))
297 + return
298 + presets.insert(0, replacement)
299 +
300 + def _find_matching_name(self, presets: list, candidate: dict) -> str:
301 + signature = self._preset_signature(candidate)
302 + for preset in presets:
303 + if self._preset_signature(preset) == signature:
304 + return str(preset.get("name") or "")
305 + return ""
306 +
307 + def _preset_signature(self, preset: dict):
308 + clean = model_config._clean_preset_for_file(preset)
309 + clean.pop("name", None)
310 + return clean
311 +
312 + @staticmethod
313 + def _preset_by_name(presets: list, name: str) -> dict | None:
314 + normalized = str(name or "").strip().casefold()
315 + return next(
316 + (
317 + preset
318 + for preset in presets
319 + if str(preset.get("name") or "").strip().casefold() == normalized
320 + ),
321 + None,
322 + )
323 +
324 + def _unique_name(self, presets: list, preferred: str) -> str:
325 + base = str(preferred or "Migrated preset").strip()
326 + candidate = base
327 + suffix = 2
328 + while self._preset_by_name(presets, candidate):
329 + candidate = f"{base} ({suffix})"
330 + suffix += 1
331 + return candidate
332 +
333 + @staticmethod
334 + def _scope_label(asset: dict) -> str:
335 + project = str(asset.get("project_name") or "").strip()
336 + profile = str(asset.get("agent_profile") or "").strip()
337 + if project and profile:
338 + return f"Project {project} · Profile {profile}"
339 + if project:
340 + return f"Project {project}"
341 + if profile:
342 + return f"Profile {profile}"
343 + return "Global"
344 +
345 + @staticmethod
346 + def _read_json_dict(path: str) -> dict | None:
347 + try:
348 + value = json.loads(files.read_file(path))
349 + except Exception:
350 + return None
351 + return value if isinstance(value, dict) else None
352 +
353 + @staticmethod
354 + def _read_yaml_list(path: str) -> list | None:
355 + if not path or not os.path.exists(path):
356 + return None
357 + try:
358 + value = yaml_helper.loads(files.read_file(path))
359 + except Exception:
360 + return None
361 + return value if isinstance(value, list) else None
362 +
363 + @staticmethod
364 + def _slot_has_identity(slot) -> bool:
365 + return isinstance(slot, dict) and bool(slot.get("provider") or slot.get("name"))
366 +
367 + def _backup(self, path: str) -> None:
368 + if not path or not os.path.exists(path):
369 + return
370 + backup_path = path + self.BACKUP_SUFFIX
371 + if not os.path.exists(backup_path):
372 + files.write_file(backup_path, files.read_file(path))
373
374 def _repair_venice_config_slots(self, config: dict) -> bool:
375 changed = False
@@ -149,24 +377,19 @@ class MigrateModelConfig(Extension):
377 changed = self._repair_venice_slot(config.get(section)) or changed
378 return changed
379
152 - def _repair_venice_presets(self, presets: list) -> bool:
380 + def _repair_venice_preset(self, preset: dict) -> bool:
381 changed = False
154 - for preset in presets:
155 - if not isinstance(preset, dict):
156 - continue
157 - for section in self.PRESET_SECTIONS:
158 - changed = self._repair_venice_slot(preset.get(section)) or changed
159 - if not any(section in preset for section in self.PRESET_SECTIONS):
160 - changed = self._repair_venice_slot(preset) or changed
382 + for section in self.PRESET_SECTIONS:
383 + changed = self._repair_venice_slot(preset.get(section)) or changed
384 + if not any(section in preset for section in self.PRESET_SECTIONS):
385 + changed = self._repair_venice_slot(preset) or changed
386 return changed
387
388 def _repair_venice_slot(self, slot) -> bool:
389 if not isinstance(slot, dict):
390 return False
391 provider = str(slot.get("provider") or "").strip().lower()
167 - if provider != "venice":
168 - return False
169 - if slot.get("kwargs") == self.VENICE_KWARGS:
392 + if provider != "venice" or slot.get("kwargs") == self.VENICE_KWARGS:
393 return False
394 slot["kwargs"] = deepcopy(self.VENICE_KWARGS)
395 return True
plugins/_model_config/extensions/python/startup_migration/_20_bootstrap_model_presets.py new
+77
@@ -0,0 +1,77 @@
1 +import os
2 +import urllib.request
3 +
4 +from helpers import files
5 +from helpers.extension import Extension
6 +from helpers.print_style import PrintStyle
7 +from plugins._model_config.helpers import model_config
8 +
9 +
10 +REMOTE_PRESETS_URL = (
11 + "https://raw.githubusercontent.com/agent0ai/a0-presets/"
12 + "main/model_presets.yaml"
13 +)
14 +FETCH_TIMEOUT_SECONDS = 5
15 +MAX_PRESETS_BYTES = 256 * 1024
16 +
17 +
18 +class BootstrapModelPresets(Extension):
19 + """Ensure a missing user preset collection is initialized at startup."""
20 +
21 + def execute(self, **kwargs):
22 + presets_path = model_config._get_presets_path()
23 +
24 + # This is the normal startup path and must not perform network I/O.
25 + if os.path.exists(presets_path):
26 + return "existing"
27 +
28 + source = "remote"
29 + try:
30 + presets = self._download_presets()
31 + except Exception as exc:
32 + source = "fallback"
33 + PrintStyle.warning(
34 + "Could not initialize model presets from agent0ai/a0-presets; "
35 + f"using fallback presets. {exc}"
36 + )
37 + try:
38 + presets = model_config.parse_preset_collection(
39 + files.read_file(model_config._get_fallback_presets_path())
40 + )
41 + except Exception as fallback_exc:
42 + PrintStyle.error(
43 + f"Fallback model presets are invalid: {fallback_exc}"
44 + )
45 + return "error"
46 +
47 + try:
48 + model_config.save_presets(presets)
49 + except Exception as exc:
50 + # Runtime preset resolution still falls back to the local file if
51 + # initialization cannot be persisted.
52 + PrintStyle.error(f"Could not persist initial model presets: {exc}")
53 + return "error"
54 +
55 + if source == "remote":
56 + PrintStyle.info("Initialized model presets from agent0ai/a0-presets.")
57 + return source
58 +
59 + def _download_presets(self) -> list:
60 + request = urllib.request.Request(
61 + REMOTE_PRESETS_URL,
62 + headers={
63 + "Accept": "application/yaml, text/yaml, text/plain",
64 + "User-Agent": "AgentZero-Model-Preset-Bootstrap",
65 + },
66 + )
67 + with urllib.request.urlopen(
68 + request,
69 + timeout=FETCH_TIMEOUT_SECONDS,
70 + ) as response:
71 + payload = response.read(MAX_PRESETS_BYTES + 1)
72 +
73 + if len(payload) > MAX_PRESETS_BYTES:
74 + raise ValueError("Remote model preset file is too large.")
75 +
76 + text = payload.decode("utf-8-sig")
77 + return model_config.parse_preset_collection(text)
plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js
+14 -1
@@ -4,6 +4,7 @@ const OVERRIDE_REVISION_KEY = "_model_config_override_revision";
4
5 let lastContextId = "";
6 let lastRevision = null;
7 +let lastScopeKey = "";
8
9 export default async function refreshSwitcherOnOverrideRevision(ctx) {
10 const snapshot = ctx?.snapshot;
@@ -12,16 +13,28 @@ export default async function refreshSwitcherOnOverrideRevision(ctx) {
13 if (!contextId) {
14 lastContextId = "";
15 lastRevision = null;
16 + lastScopeKey = "";
17 return;
18 }
19
20 const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : [];
21 const activeContext = contexts.find(item => item?.id === contextId) || null;
22 const revision = activeContext?.[OVERRIDE_REVISION_KEY] || null;
23 + const project = activeContext?.project;
24 + const projectName = typeof project === "object" ? project?.name || "" : project || "";
25 + const scopeKey = JSON.stringify([
26 + String(projectName),
27 + String(activeContext?.agent_profile || ""),
28 + ]);
29
22 - if (contextId === lastContextId && revision === lastRevision) return;
30 + if (
31 + contextId === lastContextId
32 + && revision === lastRevision
33 + && scopeKey === lastScopeKey
34 + ) return;
35
36 lastContextId = contextId;
37 lastRevision = revision;
38 + lastScopeKey = scopeKey;
39 await modelConfigStore.refreshSwitcher(contextId);
40 }
plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html
+21 -65
@@ -1,4 +1,4 @@
1 -<!-- Model Switcher - Floating Preset Selector (left-aligned with inline model display) -->
1 +<!-- Model Switcher - Floating Preset Selector -->
2 <script type="module">
3 import { store } from "/plugins/_model_config/webui/model-config-store.js";
4 </script>
@@ -26,35 +26,16 @@
26 <span class="material-symbols-outlined" style="font-size: 0.7rem;"
27 x-text="showDropdown ? 'expand_less' : 'expand_more'"></span>
28 </button>
29 -
30 - <!-- Inline active model pills (shown when a preset is active) -->
31 - <template x-if="$store.modelConfig.switcherOverride">
32 - <div class="model-switcher-active-pills">
33 - <template x-if="$store.modelConfig.getActiveModels().main">
34 - <div class="model-pill">
35 - <span class="model-pill-role">Main</span>
36 - <span class="model-pill-name" x-text="$store.modelConfig.getActiveModels().main.name"></span>
37 - </div>
38 - </template>
39 - <template x-if="$store.modelConfig.getActiveModels().utility">
40 - <div class="model-pill">
41 - <span class="model-pill-role">Util</span>
42 - <span class="model-pill-name" x-text="$store.modelConfig.getActiveModels().utility.name"></span>
43 - </div>
44 - </template>
45 - </div>
46 - </template>
47 -
29 <!-- Dropdown (opens upward) -->
30 <div class="model-switcher-dropdown" x-show="showDropdown" x-transition.opacity style="display: none;">
50 - <!-- Use Default -->
31 + <!-- Return to the project/profile selection -->
32 <template x-if="$store.modelConfig.switcherOverride">
33 <div class="model-switcher-item revert" @click="
34 $store.modelConfig.clearOverrideSwitch($store.chats?.selected || '');
35 showDropdown = false;
36 ">
37 <span class="material-symbols-outlined" style="font-size: 15px;">undo</span>
57 - <span>Use Default</span>
38 + <span>Use scoped preset</span>
39 </div>
40 </template>
41 <div class="model-switcher-divider" x-show="$store.modelConfig.switcherOverride"></div>
@@ -67,7 +48,7 @@
48 <!-- Preset list -->
49 <template x-for="preset in $store.modelConfig.switcherPresets" :key="preset.name">
50 <div class="model-switcher-item"
70 - :class="{ 'active': $store.modelConfig.switcherOverride?.preset_name === preset.name }"
51 + :class="{ 'active': $store.modelConfig.switcherEffectivePreset === preset.name }"
52 @click="
53 $store.modelConfig.selectPresetSwitch($store.chats?.selected || '', preset.name);
54 showDropdown = false;
@@ -94,6 +75,16 @@
75 </span>
76 </div>
77 </template>
78 + <template x-if="preset.embedding?.name">
79 + <div class="model-switcher-model-row">
80 + <span class="model-switcher-model-label">Embed</span>
81 + <span class="model-switcher-model-value">
82 + <span x-text="preset.embedding.provider" style="opacity:0.5;"></span>
83 + <span style="opacity:0.3; margin:0 3px;">/</span>
84 + <span x-text="preset.embedding.name"></span>
85 + </span>
86 + </div>
87 + </template>
88 </div>
89 </div>
90 </template>
@@ -101,7 +92,10 @@
92 <!-- Edit Presets shortcut -->
93 <div class="model-switcher-divider" style="opacity:0.2;"></div>
94 <div class="model-switcher-item model-switcher-edit" @click="
104 - openModal('/plugins/_model_config/webui/main.html');
95 + $store.modelConfig.openPresetEditor(
96 + $store.modelConfig.switcherEffectivePreset,
97 + $store.chats?.selected || ''
98 + );
99 showDropdown = false;
100 ">
101 <span class="material-symbols-outlined" style="font-size: 14px;">tune</span>
@@ -209,7 +203,7 @@
203 /* color: var(--color-text); */
204 }
205 .model-switcher-label {
212 - max-width: 120px;
206 + max-width: min(240px, 38vw);
207 overflow: hidden;
208 text-overflow: ellipsis;
209 }
@@ -236,43 +230,6 @@
230 overflow: hidden;
231 text-overflow: ellipsis;
232 }
239 -
240 - /* Inline active model pills */
241 - .model-switcher-active-pills {
242 - display: flex;
243 - align-items: center;
244 - gap: 6px;
245 - flex-shrink: 1;
246 - min-width: 0;
247 - }
248 - .model-pill {
249 - display: flex;
250 - align-items: center;
251 - gap: 4px;
252 - padding: 2px 8px;
253 - border-radius: 12px;
254 - background: color-mix(in srgb, var(--color-highlight) 8%, transparent);
255 - border: 1px solid color-mix(in srgb, var(--color-highlight) 15%, transparent);
256 - font-size: 0.68rem;
257 - white-space: nowrap;
258 - min-width: 0;
259 - overflow: hidden;
260 - }
261 - .model-pill-role {
262 - font-weight: 600;
263 - opacity: 0.55;
264 - font-size: 0.62rem;
265 - text-transform: uppercase;
266 - letter-spacing: 0;
267 - flex-shrink: 0;
268 - }
269 - .model-pill-name {
270 - opacity: 0.85;
271 - overflow: hidden;
272 - text-overflow: ellipsis;
273 - min-width: 0;
274 - }
275 -
233 /* Dropdown - opens upward, aligned to left */
234 .model-switcher-dropdown {
235 position: absolute;
@@ -395,10 +352,9 @@
352 font-weight: 500;
353 }
354
398 - /* Responsive: hide pills on narrow screens */
355 @media (max-width: 600px) {
400 - .model-switcher-active-pills {
401 - display: none;
356 + .model-switcher-label {
357 + max-width: 160px;
358 }
359 .agent-profile-label {
360 max-width: 92px;
plugins/_model_config/helpers/model_config.py
+332 -111
@@ -2,13 +2,16 @@ import os
2 from copy import deepcopy
3
4 import models
5 -from helpers import plugins, files
5 +from helpers import defer, plugins, files
6 +from helpers.extension import call_extensions_async
7 from helpers import yaml as yaml_helper
8 from helpers.providers import get_provider_config, get_providers
9
10 PRESETS_FILE = "presets.yaml"
10 -DEFAULT_PRESETS_FILE = "default_presets.yaml"
11 +FALLBACK_PRESETS_FILE = "mode_presets_fallback.yaml"
12 PROVIDER_METADATA_FILE = "provider_metadata.yaml"
13 +DEFAULT_PRESET_NAME = "Default"
14 +MODEL_PRESET_CONFIG_KEY = "model_preset"
15 PRESET_SCOPE_GLOBAL = "global"
16 PRESET_SCOPE_PROJECT = "project"
17 PRESET_SLOT_CONFIG_SECTIONS = {
@@ -83,20 +86,76 @@ def _get_presets_path(project_name: str | None = None) -> str:
86 return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, "_model_config", PRESETS_FILE)
87
88
86 -def _get_default_presets_path() -> str:
87 - """Return the path to the default presets file shipped with the plugin."""
89 +def _get_fallback_presets_path() -> str:
90 + """Return the plugin-local fallback used when no saved presets exist."""
91 plugin_dir = plugins.find_plugin_dir("_model_config")
89 - return files.get_abs_path(plugin_dir, DEFAULT_PRESETS_FILE) if plugin_dir else ""
92 + return files.get_abs_path(plugin_dir, FALLBACK_PRESETS_FILE) if plugin_dir else ""
93
94
95 def get_config(agent=None, project_name=None, agent_profile=None):
93 - """Get the full model config dict for the given agent/scope."""
94 - return plugins.get_plugin_config(
96 + """Get the resolved model config for an agent or selected scope."""
97 + config = plugins.get_plugin_config(
98 "_model_config",
99 agent=agent,
100 project_name=project_name,
101 agent_profile=agent_profile,
102 ) or {}
103 + # The plugin hook resolves selection-only config. Keep this boundary robust
104 + # when hooks are disabled by tests or embedding applications.
105 + if any(section in config for section in PRESET_SLOT_CONFIG_SECTIONS.values()):
106 + return config
107 + return resolve_config_settings(config)
108 +
109 +
110 +def get_configured_preset_name(agent=None, project_name=None, agent_profile=None) -> str:
111 + """Return the valid scoped preset selection, falling back to Default."""
112 + config = plugins.get_plugin_config(
113 + "_model_config",
114 + agent=agent,
115 + project_name=project_name,
116 + agent_profile=agent_profile,
117 + ) or {}
118 + name = str(config.get(MODEL_PRESET_CONFIG_KEY) or DEFAULT_PRESET_NAME).strip()
119 + return name if resolve_preset(name) else DEFAULT_PRESET_NAME
120 +
121 +
122 +def preset_to_config(preset: dict) -> dict:
123 + """Convert a complete preset into the legacy runtime model-config shape."""
124 + config: dict = {}
125 + for slot, section in PRESET_SLOT_CONFIG_SECTIONS.items():
126 + slot_config = preset.get(slot) if isinstance(preset, dict) else None
127 + config[section] = (
128 + _strip_ui_fields(slot_config, strip_api_key=False)
129 + if isinstance(slot_config, dict)
130 + else {}
131 + )
132 + return config
133 +
134 +
135 +def config_to_preset(config: dict, name: str = DEFAULT_PRESET_NAME) -> dict:
136 + """Convert legacy full model config into a preset without UI/API-key fields."""
137 + preset = {"name": str(name or "").strip()}
138 + for slot, section in PRESET_SLOT_CONFIG_SECTIONS.items():
139 + slot_config = config.get(section) if isinstance(config, dict) else None
140 + if isinstance(slot_config, dict):
141 + preset[slot] = _strip_ui_fields(slot_config, strip_api_key=True)
142 + return preset
143 +
144 +
145 +def resolve_config_settings(settings: dict | None) -> dict:
146 + """Resolve selection-only settings to the complete runtime config shape."""
147 + raw = settings if isinstance(settings, dict) else {}
148 + selected_name = str(raw.get(MODEL_PRESET_CONFIG_KEY) or DEFAULT_PRESET_NAME).strip()
149 + default_preset = resolve_preset(DEFAULT_PRESET_NAME) or {"name": DEFAULT_PRESET_NAME}
150 + selected = resolve_preset(selected_name) or default_preset
151 + config = preset_to_config(default_preset)
152 + if selected.get("name") != DEFAULT_PRESET_NAME:
153 + config = build_config_from_preset(selected, config, strip_api_key=False)
154 + config[MODEL_PRESET_CONFIG_KEY] = str(selected.get("name") or DEFAULT_PRESET_NAME)
155 + # Retained as a read-only compatibility flag for integrations that still
156 + # inspect it. The switcher is always available in the unified preset model.
157 + config["allow_chat_override"] = True
158 + return config
159
160
161 def has_project_config(project_name: str) -> bool:
@@ -107,75 +166,60 @@ def has_project_config(project_name: str) -> bool:
166
167
168 def load_project_llm_data(project_name: str) -> dict:
110 - """Build the model-settings payload shown in Project Settings."""
169 + """Build the preset-selection payload shown in Project Settings."""
170 project_config_exists = has_project_config(project_name)
112 - config = normalize_config_for_save(get_config(project_name=project_name) or {})
171 + preset_name = get_configured_preset_name(project_name=project_name)
172 return {
114 - "config": config,
115 - "config_scope": "project" if project_config_exists else "inherited",
173 "has_project_config": project_config_exists,
174 "selected_preset": {
118 - "scope": "current",
119 - "project_name": project_name,
120 - "name": "Current config",
175 + "scope": PRESET_SCOPE_GLOBAL,
176 + "project_name": "",
177 + "name": preset_name,
178 },
122 - "presets": get_combined_presets(project_name),
179 + "presets": get_combined_presets(),
180 "global_presets": get_presets(),
124 - "project_presets": get_project_presets(project_name),
181 + "project_presets": [],
182 }
183
184
185 def save_project_llm_settings(project_name: str, llm_data: object) -> None:
129 - """Persist Project Settings model data without freezing inherited globals."""
186 + """Persist only a project preset selection from Project Settings."""
187 if not isinstance(llm_data, dict):
188 return
132 -
133 - project_config_exists = has_project_config(project_name)
134 - project_presets = llm_data.get("project_presets")
135 - presets_path = _get_presets_path(project_name)
136 - if isinstance(project_presets, list) and (
137 - project_presets or files.exists(presets_path)
138 - ):
139 - save_presets(project_presets, project_name=project_name)
140 -
141 - config_to_save = None
189 selected_preset = llm_data.get("selected_preset")
143 - if isinstance(selected_preset, dict) and selected_preset.get("scope") in {
144 - PRESET_SCOPE_GLOBAL,
145 - PRESET_SCOPE_PROJECT,
146 - }:
147 - preset = resolve_preset_selection(selected_preset, project_name=project_name)
148 - if preset:
149 - base_config = get_config(project_name=project_name) or {}
150 - config_to_save = build_config_from_preset(preset, base_config)
151 -
152 - config = llm_data.get("config")
153 - config_scope = str(llm_data.get("config_scope") or "")
154 - config_is_inherited = not project_config_exists and (
155 - config_scope == "inherited"
156 - or isinstance(selected_preset, dict)
157 - and selected_preset.get("scope") == "current"
158 - )
159 - should_save_config = (
160 - project_config_exists
161 - or config_scope == "project"
162 - or not config_scope
163 - )
164 - if (
165 - config_to_save is None
166 - and isinstance(config, dict)
167 - and should_save_config
168 - and not config_is_inherited
169 - ):
170 - config_to_save = normalize_config_for_save(config)
171 -
172 - if config_to_save is not None:
173 - plugins.save_plugin_config("_model_config", project_name, "", config_to_save)
190 + if not isinstance(selected_preset, dict):
191 + return
192 + name = str(selected_preset.get("name") or "").strip()
193 + if resolve_preset(name):
194 + if not has_project_config(project_name) and name == get_configured_preset_name():
195 + return
196 + previous_embedding = get_config(project_name=project_name).get(
197 + "embedding_model",
198 + {},
199 + )
200 + plugins.save_plugin_config(
201 + "_model_config",
202 + project_name,
203 + "",
204 + {MODEL_PRESET_CONFIG_KEY: name},
205 + )
206 + current_embedding = get_config(project_name=project_name).get(
207 + "embedding_model",
208 + {},
209 + )
210 + if previous_embedding != current_embedding:
211 + defer.DeferredTask().start_task(
212 + call_extensions_async,
213 + "embedding_model_changed",
214 + )
215
216
217 def _load_presets_from_path(path: str) -> list | None:
218 if files.exists(path):
178 - data = yaml_helper.loads(files.read_file(path))
219 + try:
220 + data = yaml_helper.loads(files.read_file(path))
221 + except Exception:
222 + return None
223 if isinstance(data, list):
224 return data
225 return None
@@ -210,14 +254,34 @@ def _strip_implicit_preset_defaults(slot: str, slot_config: dict) -> dict:
254
255
256 def _clean_preset_for_file(preset: dict) -> dict:
257 + name = str(preset.get("name", "") or "").strip()
258 + if name.casefold() == DEFAULT_PRESET_NAME.casefold():
259 + name = DEFAULT_PRESET_NAME
260 cleaned = {
214 - "name": str(preset.get("name", "") or ""),
261 + "name": name,
262 }
263 + has_named_slots = any(
264 + isinstance(preset.get(slot), dict) for slot in PRESET_SLOT_CONFIG_SECTIONS
265 + )
266 for slot in PRESET_SLOT_CONFIG_SECTIONS:
267 slot_config = preset.get(slot)
268 if isinstance(slot_config, dict):
219 - slot_clean = _strip_ui_fields(slot_config, strip_api_key=False)
220 - cleaned[slot] = _strip_implicit_preset_defaults(slot, slot_clean)
269 + slot_clean = _strip_ui_fields(slot_config, strip_api_key=True)
270 + cleaned[slot] = (
271 + slot_clean
272 + if name == DEFAULT_PRESET_NAME
273 + else _strip_implicit_preset_defaults(slot, slot_clean)
274 + )
275 + # Very old presets stored the main model directly beside ``name``. Preserve
276 + # those definitions while bringing them into the canonical slot schema.
277 + if not has_named_slots and _slot_has_identity(preset):
278 + raw_chat = {
279 + key: value
280 + for key, value in preset.items()
281 + if key not in {"name", "scope", "project_name"}
282 + }
283 + raw_chat["name"] = name
284 + cleaned["chat"] = _strip_ui_fields(raw_chat, strip_api_key=True)
285 return cleaned
286
287
@@ -230,6 +294,48 @@ def clean_presets_for_file(presets: list) -> list:
294 return cleaned
295
296
297 +def validate_presets(presets: list, *, require_default: bool = True) -> list:
298 + """Validate and clean the durable global preset collection."""
299 + if not isinstance(presets, list):
300 + raise ValueError("Presets must be a list.")
301 +
302 + cleaned: list[dict] = []
303 + seen: set[str] = set()
304 + for raw in presets:
305 + if not isinstance(raw, dict):
306 + raise ValueError("Every preset must be an object.")
307 + preset = _clean_preset_for_file(raw)
308 + name = str(preset.get("name") or "").strip()
309 + if not name:
310 + raise ValueError("Preset names cannot be empty.")
311 + normalized = name.casefold()
312 + if normalized in seen:
313 + raise ValueError(f"Preset names must be unique: '{name}'.")
314 + if normalized == DEFAULT_PRESET_NAME.casefold():
315 + preset["name"] = DEFAULT_PRESET_NAME
316 + for slot, label in (
317 + ("chat", "main"),
318 + ("utility", "utility"),
319 + ("embedding", "embedding"),
320 + ):
321 + if not _slot_has_identity(preset.get(slot) or {}):
322 + raise ValueError(
323 + f"The Default preset requires a {label} model."
324 + )
325 + seen.add(normalized)
326 + cleaned.append(preset)
327 +
328 + default_index = next(
329 + (i for i, preset in enumerate(cleaned) if preset["name"] == DEFAULT_PRESET_NAME),
330 + None,
331 + )
332 + if require_default and default_index is None:
333 + raise ValueError("The Default preset cannot be deleted or renamed.")
334 + if default_index not in (None, 0):
335 + cleaned.insert(0, cleaned.pop(default_index))
336 + return cleaned
337 +
338 +
339 def normalize_config_for_save(config: dict) -> dict:
340 """Remove UI-only fields and inline API keys before storing scoped config."""
341 cleaned = deepcopy(config or {})
@@ -240,24 +346,98 @@ def normalize_config_for_save(config: dict) -> dict:
346 return cleaned
347
348
349 +def _legacy_default_preset() -> dict | None:
350 + """Build Default from a pre-v2 global config when startup migration has not run."""
351 + path = plugins.determine_plugin_asset_path(
352 + "_model_config", "", "", plugins.CONFIG_FILE_NAME
353 + )
354 + if not files.exists(path):
355 + return None
356 + try:
357 + raw = files.read_file_json(path)
358 + except Exception:
359 + return None
360 + if not isinstance(raw, dict) or not any(
361 + section in raw for section in PRESET_SLOT_CONFIG_SECTIONS.values()
362 + ):
363 + return None
364 + return config_to_preset(raw, DEFAULT_PRESET_NAME)
365 +
366 +
367 +def parse_preset_collection(text: str) -> list:
368 + """Parse, validate, and sanitize a preset YAML document."""
369 + return validate_presets(yaml_helper.loads(text))
370 +
371 +
372 +def _fallback_presets() -> list:
373 + path = _get_fallback_presets_path()
374 + if not files.exists(path):
375 + return []
376 + try:
377 + return parse_preset_collection(files.read_file(path))
378 + except Exception:
379 + return []
380 +
381 +
382 +def _ensure_default_preset(presets: list) -> list:
383 + result = [deepcopy(preset) for preset in presets if isinstance(preset, dict)]
384 + legacy_default = _legacy_default_preset()
385 + bundled_default = next(
386 + (
387 + deepcopy(preset)
388 + for preset in _fallback_presets()
389 + if isinstance(preset, dict)
390 + and str(preset.get("name") or "").strip().casefold()
391 + == DEFAULT_PRESET_NAME.casefold()
392 + ),
393 + None,
394 + )
395 + fallback_default = bundled_default or {"name": DEFAULT_PRESET_NAME}
396 + if legacy_default:
397 + for slot in PRESET_SLOT_CONFIG_SECTIONS:
398 + legacy_slot = legacy_default.get(slot)
399 + if _slot_has_identity(legacy_slot or {}):
400 + fallback_default[slot] = deepcopy(legacy_slot)
401 + default_index = next(
402 + (
403 + i
404 + for i, preset in enumerate(result)
405 + if str(preset.get("name") or "").strip().casefold()
406 + == DEFAULT_PRESET_NAME.casefold()
407 + ),
408 + None,
409 + )
410 + if default_index is not None:
411 + result[default_index]["name"] = DEFAULT_PRESET_NAME
412 + for slot in PRESET_SLOT_CONFIG_SECTIONS:
413 + if not _slot_has_identity(result[default_index].get(slot) or {}):
414 + fallback_slot = fallback_default.get(slot)
415 + if isinstance(fallback_slot, dict):
416 + result[default_index][slot] = deepcopy(fallback_slot)
417 + if default_index:
418 + result.insert(0, result.pop(default_index))
419 + return result
420 +
421 + result.insert(0, fallback_default)
422 + return result
423 +
424 +
425 def get_presets(project_name: str | None = None) -> list:
244 - """Get global model presets list (not scoped to project/agent)."""
426 + """Get global presets with the required Default preset first."""
427 if project_name:
428 return get_project_presets(project_name)
429
430 path = _get_presets_path()
431 presets = _load_presets_from_path(path)
432 if presets is not None:
251 - return presets
433 + return _ensure_default_preset(presets)
434
253 - # Fall back to defaults bundled with the plugin
254 - default_path = _get_default_presets_path()
255 - default_presets = _load_presets_from_path(default_path) if default_path else None
256 - return default_presets or []
435 + # Fall back to the repository-shipped offline collection.
436 + return _ensure_default_preset(_fallback_presets())
437
438
439 def get_project_presets(project_name: str) -> list:
260 - """Get presets stored beside the project-scoped _model_config config."""
440 + """Load legacy project presets for migration/compatibility only."""
441 return _load_presets_from_path(_get_presets_path(project_name)) or []
442
443
@@ -270,33 +450,57 @@ def _with_preset_metadata(preset: dict, scope: str, project_name: str = "") -> d
450
451
452 def get_combined_presets(project_name: str | None = None) -> list:
273 - """Get presets with explicit API metadata for unambiguous UI/API selection."""
274 - presets = [
453 + """Get global presets with API metadata (project definitions are retired)."""
454 + return [
455 _with_preset_metadata(preset, PRESET_SCOPE_GLOBAL)
456 for preset in get_presets()
457 if isinstance(preset, dict)
458 ]
279 - if project_name:
280 - presets.extend(
281 - _with_preset_metadata(preset, PRESET_SCOPE_PROJECT, project_name)
282 - for preset in get_project_presets(project_name)
283 - if isinstance(preset, dict)
284 - )
285 - return presets
459
460
461 def save_presets(presets: list, project_name: str | None = None) -> None:
289 - """Save the presets list for the requested scope."""
462 + """Save global presets while enforcing the immutable Default identity."""
463 + if project_name:
464 + raise ValueError("Project-specific preset definitions are no longer supported.")
465 + cleaned = validate_presets(presets)
466 path = _get_presets_path(project_name)
291 - files.write_file(path, yaml_helper.dumps(clean_presets_for_file(presets)))
467 + files.write_file(path, yaml_helper.dumps(cleaned))
468 +
469 +
470 +def update_preset_from_config(name: str, config: dict) -> dict:
471 + """Replace one global preset's model slots from a legacy config payload."""
472 + target = resolve_preset(name)
473 + if not target:
474 + raise ValueError(f"Preset '{name}' was not found.")
475 + canonical_name = str(target.get("name") or DEFAULT_PRESET_NAME)
476 + replacement = config_to_preset(config, canonical_name)
477 + if canonical_name == DEFAULT_PRESET_NAME:
478 + for slot in PRESET_SLOT_CONFIG_SECTIONS:
479 + if not _slot_has_identity(replacement.get(slot) or {}):
480 + current_slot = target.get(slot)
481 + if isinstance(current_slot, dict):
482 + replacement[slot] = deepcopy(current_slot)
483 + presets = get_presets()
484 + updated = False
485 + for index, preset in enumerate(presets):
486 + if str(preset.get("name") or "").casefold() == canonical_name.casefold():
487 + presets[index] = replacement
488 + updated = True
489 + break
490 + if not updated:
491 + raise ValueError(f"Preset '{name}' was not found.")
492 + save_presets(presets)
493 + return replacement
494
495
496 def reset_presets(project_name: str | None = None) -> list:
497 """Delete user presets for the scope. Global reset falls back to bundled defaults."""
498 + if project_name:
499 + raise ValueError("Project-specific preset definitions are no longer supported.")
500 path = _get_presets_path(project_name)
501 if os.path.exists(path):
502 os.remove(path)
299 - return get_project_presets(project_name) if project_name else get_presets()
503 + return get_presets()
504
505
506 def resolve_preset(
@@ -307,14 +511,11 @@ def resolve_preset(
511 ) -> dict | None:
512 """Resolve a preset by explicit scope so same-name presets are unambiguous."""
513 if scope == PRESET_SCOPE_PROJECT:
310 - if not project_name:
311 - return None
312 - presets = get_project_presets(project_name)
313 - else:
314 - presets = get_presets()
514 + return None
515 + presets = get_presets()
516
517 for p in presets:
317 - if p.get("name") == name:
518 + if str(p.get("name") or "").casefold() == str(name or "").strip().casefold():
519 return p
520 return None
521
@@ -491,39 +692,60 @@ def _resolve_override(agent) -> dict | None:
692 return override
693
694
494 -def get_chat_model_config(agent=None) -> dict:
495 - """Get chat model config, with per-chat override if active."""
496 - cfg = get_config(agent)
695 +def get_effective_preset_name(agent=None) -> str:
696 + """Return the valid preset used by a chat, including its explicit override."""
697 + if agent:
698 + override = getattr(agent, "context", None)
699 + override = override.get_data("chat_model_override") if override else None
700 + if isinstance(override, dict):
701 + name = str(override.get("preset_name") or "").strip()
702 + preset = resolve_preset(name) if name else None
703 + if preset:
704 + return str(preset.get("name") or DEFAULT_PRESET_NAME)
705 + config = get_config(agent)
706 + return str(config.get(MODEL_PRESET_CONFIG_KEY) or DEFAULT_PRESET_NAME)
707 +
708 +
709 +def get_effective_config(agent=None) -> dict:
710 + """Resolve the complete model config, including a per-chat preset selection."""
711 + config = get_config(agent)
712 + raw_override = None
713 + if agent and getattr(agent, "context", None):
714 + raw_override = agent.context.get_data("chat_model_override")
715 + uses_named_preset = isinstance(raw_override, dict) and bool(
716 + raw_override.get("preset_name")
717 + )
718 override = _resolve_override(agent)
719 if override:
720 + base = (
721 + preset_to_config(resolve_preset(DEFAULT_PRESET_NAME) or {})
722 + if uses_named_preset
723 + else config
724 + )
725 config = build_config_from_preset(
726 override,
501 - cfg,
727 + base,
728 strip_api_key=False,
503 - slots=("chat",),
729 )
505 - return config.get("chat_model", {})
506 - return cfg.get("chat_model", {})
730 + if uses_named_preset:
731 + config[MODEL_PRESET_CONFIG_KEY] = get_effective_preset_name(agent)
732 + config["allow_chat_override"] = True
733 + return config
734 +
735 +
736 +def get_chat_model_config(agent=None) -> dict:
737 + """Get chat model config, with per-chat override if active."""
738 + return get_effective_config(agent).get("chat_model", {})
739
740
741 def get_utility_model_config(agent=None) -> dict:
742 """Get utility model config, with per-chat override if active."""
511 - cfg = get_config(agent)
512 - override = _resolve_override(agent)
513 - if override:
514 - config = build_config_from_preset(
515 - override,
516 - cfg,
517 - strip_api_key=False,
518 - slots=("utility",),
519 - )
520 - return config.get("utility_model", {})
521 - return cfg.get("utility_model", {})
743 + return get_effective_config(agent).get("utility_model", {})
744
745
746 def get_embedding_model_config(agent=None) -> dict:
525 - """Get embedding model config."""
526 - cfg = get_config(agent)
747 + """Get embedding model config from the effective preset."""
748 + cfg = get_effective_config(agent)
749 model_cfg = deepcopy(cfg.get("embedding_model", {}))
750 provider = str(model_cfg.get("provider") or "").strip().lower()
751 name = str(model_cfg.get("name") or "").strip().strip('"').strip("'")
@@ -545,9 +767,8 @@ def get_embedding_model_config(agent=None) -> dict:
767
768
769 def is_chat_override_allowed(agent=None) -> bool:
548 - """Check if per-chat model override is enabled."""
549 - cfg = get_config(agent)
550 - return bool(cfg.get("allow_chat_override", False))
770 + """The unified preset switcher is always enabled."""
771 + return True
772
773
774 def get_ctx_history(agent=None) -> float:
@@ -652,7 +873,7 @@ def has_provider_api_key(provider: str, configured_api_key: str = "", model_type
873
874 def get_missing_api_key_providers(agent=None) -> list[dict]:
875 """Check which configured providers are missing API keys."""
655 - cfg = get_config(agent)
876 + cfg = get_effective_config(agent)
877 missing = []
878
879 checks = [
plugins/_model_config/hooks.py
+22 -7
@@ -1,8 +1,23 @@
1 +from plugins._model_config.helpers import model_config
2 +
3 +
4 +def get_plugin_config(default=None, **kwargs):
5 + """Expose the legacy complete config shape to runtime callers."""
6 + return model_config.resolve_config_settings(default)
7 +
8 +
9 def save_plugin_config(result=None, settings=None, **kwargs):
2 - if settings and isinstance(settings, dict):
3 - # Remove transient UI-only fields before persisting
4 - for section in ("chat_model", "utility_model", "embedding_model"):
5 - if section in settings and isinstance(settings[section], dict):
6 - settings[section].pop("_kwargs_text", None)
7 - settings[section].pop("api_key", None)
8 - return settings
10 + """Persist only the scoped preset selection."""
11 + raw = settings if isinstance(settings, dict) else {}
12 + name = str(
13 + raw.get(model_config.MODEL_PRESET_CONFIG_KEY)
14 + or model_config.DEFAULT_PRESET_NAME
15 + ).strip()
16 + preset = model_config.resolve_preset(name)
17 + return {
18 + model_config.MODEL_PRESET_CONFIG_KEY: (
19 + str(preset.get("name"))
20 + if preset
21 + else model_config.DEFAULT_PRESET_NAME
22 + )
23 + }
plugins/_model_config/mode_presets_fallback.yaml new
+54
@@ -0,0 +1,54 @@
1 +- name: Default
2 + chat:
3 + provider: openrouter
4 + name: openai/gpt-5.6-terra
5 + api_base: ""
6 + ctx_length: 200000
7 + ctx_history: 0.7
8 + vision: true
9 + max_embeds: 10
10 + rl_requests: 0
11 + rl_input: 0
12 + rl_output: 0
13 + kwargs: {}
14 + utility:
15 + provider: openrouter
16 + name: google/gemini-3.1-flash-lite
17 + api_base: ""
18 + ctx_length: 128000
19 + ctx_input: 0.7
20 + rl_requests: 0
21 + rl_input: 0
22 + rl_output: 0
23 + kwargs: {}
24 + embedding:
25 + provider: huggingface
26 + name: sentence-transformers/all-MiniLM-L6-v2
27 + api_base: ""
28 + rl_requests: 0
29 + rl_input: 0
30 + kwargs: {}
31 +
32 +- name: Efficiency
33 + chat:
34 + provider: openrouter
35 + name: z-ai/glm-5.2
36 + api_base: ""
37 + ctx_length: 128000
38 + ctx_history: 0.7
39 + vision: false
40 + utility:
41 + provider: openrouter
42 + name: deepseek/deepseek-v4-flash
43 +
44 +- name: Power
45 + chat:
46 + provider: openrouter
47 + name: openai/gpt-5.6-sol
48 + api_base: ""
49 + ctx_length: 200000
50 + ctx_history: 0.7
51 + vision: true
52 + utility:
53 + provider: openrouter
54 + name: openai/gpt-5.6-luna
plugins/_model_config/plugin.yaml
+2 -2
@@ -1,7 +1,7 @@
1 name: _model_config
2 title: Model Configuration
3 -description: Manages LLM model selection and configuration for chat, utility, and embedding models. Supports per-project and per-agent overrides with optional per-chat model switching.
4 -version: 1.0.0
3 +description: Manages reusable model presets and selects them globally, per project, per agent profile, and per chat.
4 +version: 2.1.0
5 always_enabled: true
6 settings_sections:
7 - agent
plugins/_model_config/webui/config.html
+15 -71
@@ -1,83 +1,27 @@
1 <html>
2 <head>
3 - <title>Model Configuration</title>
3 + <title>Model Preset</title>
4 <script type="module">
5 import { store } from "/plugins/_model_config/webui/model-config-store.js";
6 </script>
7 </head>
8
9 <body>
10 - <div x-data
11 - x-init="
12 - await $store.modelConfig.ensureLoaded();
13 - $store.modelConfig.resetApiKeyDrafts();
14 - await $store.modelConfig.refreshApiKeyStatus();
15 - $store.modelConfig.installSettingsHooks(context);
16 - ">
17 - <template x-if="config && $store.modelConfig._loaded">
18 - <div class="model-config-sections">
19 -
20 - <!-- Model Sections (Main, Utility, Embedding) -->
21 - <template x-for="section in $store.modelConfig.MODEL_SECTIONS" :key="section.key">
22 - <div class="model-section"
23 - x-data="{ get model() { return config[section.key]; }, modelType: section.key.replace('_model', ''), providers: $store.modelConfig.getProviders(section.key), searchType: $store.modelConfig.getSearchType(section.key), apiKeyMode: 'store' }">
24 - <div class="section-title" x-text="section.title"></div>
25 - <div class="section-description" x-text="section.desc"></div>
26 - <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
27 - </div>
28 - </template>
29 -
30 -
31 - <!-- Per-Chat Override -->
32 - <div class="model-section">
33 - <div class="section-title">Per-Chat Override</div>
34 - <div class="section-description">Allow switching model presets from the chat navigation bar. New chats inherit the active preset.</div>
35 - <div class="field">
36 - <div class="field-label">
37 - <div class="field-title">Enable model switcher</div>
38 - <div class="field-description">Show a preset switcher in the chat navigation bar. Overrides main and utility model for the current chat.</div>
39 - </div>
40 - <div class="field-control">
41 - <label class="toggle">
42 - <input type="checkbox" x-model="config.allow_chat_override" />
43 - <span class="toggler"></span>
44 - </label>
45 - </div>
46 - </div>
47 - <div class="override-presets-link" x-show="config.allow_chat_override">
48 - <button class="text-button" @click="openModal('/plugins/_model_config/webui/main.html')">
49 - <span class="material-symbols-outlined">tune</span>
50 - <span>Edit Presets</span>
51 - </button>
52 - </div>
53 - </div>
54 -
55 - </div>
56 - </template>
10 + <div x-data="$store.modelConfig.createScopedPresetSelector(context)"
11 + x-init="await init()">
12 + <div class="section-title">Model Preset</div>
13 + <div class="section-description">
14 + This scope stores only a preset selection. Edit the preset once and every scope using it stays in sync.
15 + </div>
16 + <div class="scoped-preset-overview">
17 + <x-component path="/plugins/_model_config/webui/preset-overview.html"></x-component>
18 + </div>
19 </div>
20
59 -<style>
60 - .model-config-sections {
61 - display: flex;
62 - flex-direction: column;
63 - gap: 16px;
64 - }
65 - .model-section {
66 - border: 1px solid var(--color-border);
67 - border-radius: 8px;
68 - padding: 16px;
69 - }
70 - .model-section .section-title {
71 - margin-top: 0;
72 - }
73 - .override-presets-link {
74 - margin-top: 4px;
75 - }
76 - .override-presets-link .text-button {
77 - font-size: 0.78rem !important;
78 - gap: 4px;
79 - }
80 -
81 -</style>
21 + <style>
22 + .scoped-preset-overview {
23 + margin-top: 1rem;
24 + }
25 + </style>
26 </body>
27 </html>
plugins/_model_config/webui/main.html
+148 -123
@@ -1,6 +1,6 @@
1 <html>
2 <head>
3 - <title>Model Presets</title>
3 + <title>Edit Model Presets</title>
4 <script type="module">
5 import { store } from "/plugins/_model_config/webui/model-config-store.js";
6 </script>
@@ -10,158 +10,183 @@
10 <div x-data
11 x-init="
12 await $store.modelConfig.ensureLoaded();
13 - await $store.modelConfig.loadGlobalPresets();
13 $store.modelConfig.resetApiKeyDrafts();
14 await $store.modelConfig.refreshApiKeyStatus();
15 + await $store.modelConfig.loadGlobalPresets();
16 + await $store.modelConfig.preparePresetEditor($store.chats?.selected || '');
17 ">
18 <template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
18 - <div class="presets-page" x-data="$store.modelConfig.createPresetEditor()">
19 - <div class="presets-header">
20 - <div class="field-title" style="font-size:1rem;">Model Presets</div>
21 - <div class="field-description">Global presets shared across all projects and agents. Used by the model switcher in the chat area.</div>
19 + <div class="preset-editor"
20 + x-data="$store.modelConfig.createPresetEditor($store.modelConfig.presetEditorInitialName)">
21 + <div class="preset-editor-intro">
22 + <div class="field-title">Model Presets</div>
23 + <div class="field-description">
24 + Each preset contains the complete main, utility, and embedding model setup. Default is always available.
25 + </div>
26 + </div>
27 +
28 + <div class="preset-editor-toolbar">
29 + <label class="preset-editor-select">
30 + <span class="field-title">Preset</span>
31 + <select x-model.number="selectedKey">
32 + <template x-for="preset in presets" :key="preset._key">
33 + <option :value="preset._key" x-text="preset.name"></option>
34 + </template>
35 + </select>
36 + </label>
37 + <button type="button" class="button" @click="addPreset()">
38 + <span class="icon material-symbols-outlined">add</span>
39 + Add
40 + </button>
41 + <button type="button" class="button"
42 + x-show="canDeleteSelected"
43 + @click="$confirmClick($event, () => removeSelectedPreset())">
44 + <span class="icon material-symbols-outlined">delete</span>
45 + Delete
46 + </button>
47 </div>
48
24 - <template x-for="(preset, idx) in presets" :key="preset._key">
25 - <div class="preset-card" x-data="{ expanded: false }">
26 - <div class="preset-card-header" @click="expanded = !expanded">
27 - <span class="material-symbols-outlined preset-expand-icon"
28 - :style="expanded ? 'transform:rotate(90deg)' : ''"
29 - style="font-size:16px; transition:transform 0.15s ease;">chevron_right</span>
30 - <span class="preset-card-name" x-text="preset.name || '(unnamed)'"></span>
31 - <span class="preset-card-summary" x-show="!expanded"
32 - x-text="(preset.chat?.provider ? preset.chat.provider + '/' : '') + (preset.chat?.name || '')"></span>
33 - <button class="text-button preset-delete-btn"
34 - @click.stop="$confirmClick($event, () => removePreset(idx))"
35 - title="Remove preset">
36 - <span class="material-symbols-outlined" style="font-size:16px;">close</span>
37 - </button>
38 - </div>
39 -
40 - <div class="preset-card-body" x-show="expanded" x-transition.opacity>
41 - <div class="field">
49 + <template x-if="selectedPreset">
50 + <div class="preset-editor-body">
51 + <template x-if="canRenameSelected">
52 + <div class="field preset-name-field">
53 <div class="field-label">
54 <div class="field-title">Preset name</div>
44 - <div class="field-description">Display name shown in the model switcher dropdown.</div>
55 + <div class="field-description">Shown in settings and the chat model switcher.</div>
56 + </div>
57 + <div class="field-control">
58 + <input type="text"
59 + x-model="selectedPreset.name"
60 + placeholder="Preset name">
61 </div>
46 - <div class="field-control"><input type="text" x-model="preset.name" placeholder="e.g. GPT-4o, Claude Sonnet"
47 - @blur="if (!preset.name.trim()) preset.name = 'Preset ' + (idx + 1)" required /></div>
62 </div>
63 + </template>
64
50 - <div class="preset-subheader">Main Model</div>
51 - <div x-data="{ model: preset.chat, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
65 + <section class="preset-model-section">
66 + <div class="preset-model-heading">
67 + <div class="section-title">Main Model</div>
68 + <div class="section-description">Primary model for conversations, reasoning, and tools.</div>
69 + </div>
70 + <div x-data="{ get model() { return selectedPreset.chat; }, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
71 <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
72 </div>
73 + </section>
74
55 - <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional &#x2014; falls back to the configured Utility Model)</span></div>
56 - <div x-data="{ model: preset.utility, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store', providerFallback: preset.chat.provider, apiBaseFallback: preset.chat.api_base }">
75 + <section class="preset-model-section">
76 + <div class="preset-model-heading">
77 + <div class="section-title">Utility Model</div>
78 + <div class="section-description">Faster helper model for summaries and background work.</div>
79 + </div>
80 + <div x-data="{ get model() { return selectedPreset.utility; }, modelType: 'utility', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store', get providerFallback() { return selectedPreset.chat.provider; }, get apiBaseFallback() { return selectedPreset.chat.api_base; } }">
81 <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
82 </div>
59 - </div>
83 + </section>
84 +
85 + <section class="preset-model-section">
86 + <div class="preset-model-heading">
87 + <div class="section-title">Embedding Model</div>
88 + <div class="section-description">Creates vectors for memory and knowledge retrieval.</div>
89 + </div>
90 + <div x-data="{ get model() { return selectedPreset.embedding; }, modelType: 'embedding', providers: $store.modelConfig.embeddingProviders, searchType: 'embedding', apiKeyMode: 'store' }">
91 + <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
92 + </div>
93 + </section>
94 </div>
95 </template>
96
63 - <div class="presets-actions">
64 - <button class="text-button preset-add-btn" @click="addPreset()">
65 - <span class="material-symbols-outlined">add</span>
66 - <span>Add Preset</span>
67 - </button>
68 - <button class="text-button" @click="(async () => {
69 - await import('/components/plugins/plugin-settings-store.js');
70 - await $store.pluginSettingsPrototype.openConfig('_model_config');
71 - refreshPresets();
72 - })()">
73 - <span class="material-symbols-outlined">settings</span>
74 - <span>Settings</span>
75 - </button>
76 - <button class="text-button" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
97 + <div class="preset-editor-secondary-actions">
98 + <button type="button" class="text-button" @click="$store.modelConfig.openApiKeysFromSummary()">
99 <span class="material-symbols-outlined">key</span>
78 - <span>API Keys</span>
100 + API Keys
101 + </button>
102 + <button type="button" class="text-button"
103 + @click="$confirmClick($event, () => resetPresets())">
104 + <span class="material-symbols-outlined">restart_alt</span>
105 + Restore bundled presets
106 </button>
107 </div>
108
82 - <div class="presets-footer">
83 - <button class="button" @click="$confirmClick($event, () => resetPresets())">
84 - <span class="icon material-symbols-outlined">restart_alt</span> Reset to default
85 - </button>
86 - <button class="button" @click="savePresets()">
87 - <span class="icon material-symbols-outlined">save</span> Save Presets
88 - </button>
109 + <div class="modal-footer" data-modal-footer>
110 + <button type="button" class="btn btn-ok"
111 + @click="if (await savePresets()) window.closeModal?.()">Save</button>
112 + <button type="button" class="btn btn-cancel"
113 + @click="window.closeModal?.()">Close</button>
114 </div>
115 </div>
116 </template>
117 </div>
118
119 <style>
95 - .presets-page {
96 - display: flex;
97 - flex-direction: column;
98 - gap: 8px;
99 - }
100 - .presets-header {
101 - margin-bottom: 8px;
102 - }
103 - .presets-footer {
104 - margin-top: 12px;
105 - display: flex;
106 - justify-content: flex-end;
107 - gap: 8px;
108 - }
109 - .preset-card {
110 - border: 1px solid var(--color-border);
111 - border-radius: 6px;
112 - }
113 - .preset-card-header {
114 - display: flex;
115 - align-items: center;
116 - gap: 6px;
117 - padding: 8px 10px;
118 - cursor: pointer;
119 - font-size: 0.85rem;
120 - }
121 - .preset-card-header:hover {
122 - background: var(--color-background-hover, rgba(255,255,255,0.04));
123 - }
124 - .preset-card-name {
125 - font-weight: 500;
126 - }
127 - .preset-card-summary {
128 - flex: 1;
129 - text-align: right;
130 - opacity: 0.5;
131 - font-size: 0.75rem;
132 - overflow: hidden;
133 - text-overflow: ellipsis;
134 - white-space: nowrap;
135 - }
136 - .preset-delete-btn {
137 - margin-left: auto;
138 - opacity: 0.5;
139 - padding: 2px !important;
140 - }
141 - .preset-delete-btn:hover {
142 - opacity: 1;
143 - color: var(--color-error, #f44) !important;
144 - }
145 - .preset-card-body {
146 - padding: 4px 12px 12px;
147 - border-top: 1px solid var(--color-border);
148 - }
149 - .preset-subheader {
150 - font-size: 0.8rem;
151 - font-weight: 500;
152 - opacity: 0.7;
153 - margin: 10px 0 4px;
154 - padding-top: 8px;
155 - border-top: 1px dashed var(--color-border);
156 - }
157 - .presets-actions {
158 - display: flex;
159 - gap: 12px;
160 - flex-wrap: wrap;
161 - align-items: center;
162 - margin-top: 4px;
163 - }
120 + .preset-editor,
121 + .preset-editor-body {
122 + display: flex;
123 + flex-direction: column;
124 + gap: 1rem;
125 + }
126 +
127 + .preset-editor-intro .field-title {
128 + font-size: 1rem;
129 + }
130 +
131 + .preset-editor-toolbar {
132 + display: flex;
133 + align-items: flex-end;
134 + gap: 0.5rem;
135 + padding: 0.8rem;
136 + border: 1px solid var(--color-border);
137 + border-radius: 8px;
138 + background: var(--color-input);
139 + }
140 +
141 + .preset-editor-select {
142 + display: flex;
143 + flex: 1 1 auto;
144 + flex-direction: column;
145 + gap: 0.35rem;
146 + min-width: 0;
147 + margin: 0;
148 + }
149 +
150 + .preset-editor-select select {
151 + width: 100%;
152 + }
153 +
154 + .preset-name-field,
155 + .preset-model-section {
156 + padding: 1rem;
157 + border: 1px solid var(--color-border);
158 + border-radius: 8px;
159 + }
160 +
161 + .preset-model-heading {
162 + margin-bottom: 0.75rem;
163 + }
164 +
165 + .preset-model-heading .section-title {
166 + margin-top: 0;
167 + }
168 +
169 + .preset-editor-secondary-actions {
170 + display: flex;
171 + flex-wrap: wrap;
172 + gap: 1rem;
173 + }
174 +
175 + @media (max-width: 640px) {
176 + .preset-editor-toolbar {
177 + align-items: stretch;
178 + flex-wrap: wrap;
179 + }
180 +
181 + .preset-editor-select {
182 + flex-basis: 100%;
183 + }
184
185 + .preset-editor-toolbar .button {
186 + flex: 1 1 auto;
187 + justify-content: center;
188 + }
189 + }
190 </style>
191 </body>
192 </html>
plugins/_model_config/webui/model-config-store.js
+348 -45
@@ -80,7 +80,12 @@ function presetDefaultValuesEqual(value, defaultValue) {
80 return JSON.stringify(value) === JSON.stringify(defaultValue);
81 }
82
83 -function cleanPresetSlot(slot, stripApiKey = true, slotKey = '') {
83 +function cleanPresetSlot(
84 + slot,
85 + stripApiKey = true,
86 + slotKey = '',
87 + preserveImplicitDefaults = false,
88 +) {
89 const clean = {};
90 const implicitDefaults = IMPLICIT_PRESET_SLOT_DEFAULTS[slotKey] || {};
91 for (const [key, value] of Object.entries(slot || {})) {
@@ -92,7 +97,11 @@ function cleanPresetSlot(slot, stripApiKey = true, slotKey = '') {
97 }
98 if (key === 'kwargs' && isBlankPresetValue(value)) continue;
99 if (isBlankPresetValue(value)) continue;
95 - if (key in implicitDefaults && presetDefaultValuesEqual(value, implicitDefaults[key])) continue;
100 + if (
101 + !preserveImplicitDefaults
102 + && key in implicitDefaults
103 + && presetDefaultValuesEqual(value, implicitDefaults[key])
104 + ) continue;
105 clean[key] = value;
106 }
107 return clean;
@@ -151,6 +160,7 @@ export function configFromPreset(preset, baseConfig, stripApiKey = true) {
160 // ── Alpine Store ──
161
162 const API_BASE = "/plugins/_model_config";
163 +export const DEFAULT_PRESET_NAME = "Default";
164
165 export const store = createStore("modelConfig", {
166 // Core state
@@ -168,12 +178,16 @@ export const store = createStore("modelConfig", {
178 // Global presets state
179 globalPresets: [],
180 _presetsLoaded: false,
181 + _lastPresetReferenceChanges: {},
182
183 // Model summary state
184 modelsSummary: [],
185 + modelsSummaryPreset: DEFAULT_PRESET_NAME,
186 modelsSummaryLoading: false,
187 _modelsSummaryLoaded: false,
188 _modelsSummaryPromise: null,
189 + presetEditorInitialName: DEFAULT_PRESET_NAME,
190 + _presetEditorSelectionPinned: false,
191
192 // Switcher state (from mixin)
193 ...switcherState,
@@ -189,12 +203,26 @@ export const store = createStore("modelConfig", {
203 // ── Core methods ──
204
205 _normalizePresets(rawPresets) {
192 - return (rawPresets || []).map(p => ({
193 - name: p.name || '',
194 - chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.chat?.kwargs), ...(p.chat || {}) },
195 - utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.utility?.kwargs), ...(p.utility || {}) },
196 - embedding: p.embedding ? { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: kwargsToText(p.embedding?.kwargs), ...(p.embedding || {}) } : undefined,
197 - }));
206 + const source = (rawPresets || []).filter(p => p && typeof p === 'object');
207 + const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {};
208 + const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) });
209 + const defaultConfig = {
210 + chat_model: slot(rawDefault.chat),
211 + utility_model: slot(rawDefault.utility),
212 + embedding_model: slot(rawDefault.embedding),
213 + };
214 +
215 + return source.map(p => {
216 + const effective = String(p.name || '').toLowerCase() === 'default'
217 + ? defaultConfig
218 + : configFromPreset(p, defaultConfig, true);
219 + return {
220 + name: p.name || '',
221 + chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) },
222 + utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) },
223 + embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) },
224 + };
225 + });
226 },
227
228 async ensureLoaded() {
@@ -232,12 +260,13 @@ export const store = createStore("modelConfig", {
260 this._loaded = true;
261 },
262
235 - async _fetchConfigData() {
263 + async _fetchConfigData(input = {}) {
264 const res = await fetchApi(`${API_BASE}/model_config_get`, {
265 method: 'POST',
266 headers: { 'Content-Type': 'application/json' },
239 - body: JSON.stringify({})
267 + body: JSON.stringify(input)
268 });
269 + if (!res.ok) throw new Error(await res.text());
270 return await res.json();
271 },
272
@@ -256,49 +285,109 @@ export const store = createStore("modelConfig", {
285 ? context._toComparableJson(config)
286 : null;
287
259 - this.initConfigFields(config);
288 + const selected = String(config.model_preset || DEFAULT_PRESET_NAME).trim();
289 + context.settings = {
290 + model_preset: this.globalPresets.some(p => p.name === selected)
291 + ? selected
292 + : DEFAULT_PRESET_NAME,
293 + };
294
295 if (
296 refreshCleanSnapshot &&
297 typeof context._toComparableJson === 'function' &&
298 context.settingsSnapshotJson === snapshotBeforeInit
299 ) {
266 - context.settingsSnapshotJson = context._toComparableJson(config);
300 + context.settingsSnapshotJson = context._toComparableJson(context.settings);
301 }
302 },
303
304 // Global presets
271 - createPresetEditor() {
305 + createPresetEditor(initialName = '') {
306 const store = this;
307 let nextPresetKey = 0;
308 const presets = clonePlain(this.globalPresets).map(preset => ({
309 ...preset,
310 + _originalName: preset.name,
311 _key: nextPresetKey++,
312 }));
313 + const initial = presets.find(p => p.name === initialName)
314 + || presets.find(p => p.name === DEFAULT_PRESET_NAME)
315 + || presets[0]
316 + || null;
317
318 return {
319 presets,
320 + selectedKey: initial?._key ?? null,
321 + get selectedPreset() {
322 + return this.presets.find(p => p._key === this.selectedKey) || null;
323 + },
324 + get canRenameSelected() {
325 + return !!this.selectedPreset && this.selectedPreset._originalName !== DEFAULT_PRESET_NAME;
326 + },
327 + get canDeleteSelected() {
328 + return this.canRenameSelected;
329 + },
330 + uniquePresetName(preferred = 'New Preset') {
331 + const names = new Set(this.presets.map(p => String(p.name || '').toLowerCase()));
332 + let candidate = preferred;
333 + let suffix = 2;
334 + while (names.has(candidate.toLowerCase())) candidate = `${preferred} ${suffix++}`;
335 + return candidate;
336 + },
337 addPreset() {
282 - this.presets = [...this.presets, {
338 + const base = clonePlain(
339 + this.presets.find(p => p.name === DEFAULT_PRESET_NAME)
340 + || this.presets[0]
341 + || {
342 + chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
343 + utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
344 + embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
345 + }
346 + );
347 + const preset = {
348 + ...base,
349 _key: nextPresetKey++,
284 - name: `Preset ${this.presets.length + 1}`,
285 - chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
286 - utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
287 - }];
350 + _originalName: '',
351 + name: this.uniquePresetName(),
352 + };
353 + this.presets = [...this.presets, preset];
354 + this.selectedKey = preset._key;
355 },
289 - removePreset(index) {
290 - this.presets.splice(index, 1);
291 - this.presets = [...this.presets];
356 + removeSelectedPreset() {
357 + if (!this.canDeleteSelected) return;
358 + const removeKey = this.selectedKey;
359 + this.presets = this.presets.filter(p => p._key !== removeKey);
360 + this.selectedKey = this.presets.find(p => p.name === DEFAULT_PRESET_NAME)?._key
361 + ?? this.presets[0]?._key
362 + ?? null;
363 },
364 async savePresets() {
294 - await store.persistAllDirtyApiKeys();
295 - await store.saveGlobalPresets(this.presets);
365 + if (this.selectedPreset && !String(this.selectedPreset.name || '').trim()) {
366 + globalThis.justToast?.('Preset names cannot be empty.', 'error');
367 + return false;
368 + }
369 + try {
370 + await store.persistAllDirtyApiKeys();
371 + } catch (e) {
372 + console.error('Failed to save API keys:', e);
373 + globalThis.justToast?.(e?.message || 'Failed to save API keys.', 'error');
374 + return false;
375 + }
376 + if (!await store.saveGlobalPresets(this.presets)) return false;
377 + this.refreshPresets();
378 + return true;
379 },
380 refreshPresets() {
381 + const selectedName = this.selectedPreset?.name || initialName || DEFAULT_PRESET_NAME;
382 this.presets = clonePlain(store.globalPresets).map(preset => ({
383 ...preset,
384 + _originalName: preset.name,
385 _key: nextPresetKey++,
386 }));
387 + this.selectedKey = this.presets.find(p => p.name === selectedName)?._key
388 + ?? this.presets.find(p => p.name === DEFAULT_PRESET_NAME)?._key
389 + ?? this.presets[0]?._key
390 + ?? null;
391 },
392 async resetPresets() {
393 if (!await store.resetGlobalPresets()) return;
@@ -315,6 +404,7 @@ export const store = createStore("modelConfig", {
404 headers: { 'Content-Type': 'application/json' },
405 body: JSON.stringify({ action: 'get' })
406 });
407 + if (!res.ok) throw new Error(await res.text());
408 const data = await res.json();
409 this.globalPresets = this._normalizePresets(data.presets);
410 } catch (e) {
@@ -325,45 +415,75 @@ export const store = createStore("modelConfig", {
415 },
416
417 async saveGlobalPresets(presets) {
418 + const previousNames = this.globalPresets.map(p => String(p.name || '')).filter(Boolean);
419 // Strip UI-only and globally-managed fields before saving
420 const clean = presets.map(p => {
421 const c = { name: p.name };
422 + const isDefault = p._originalName === DEFAULT_PRESET_NAME;
423 for (const slot of ['chat', 'utility']) {
424 if (p[slot]) {
333 - const rest = cleanPresetSlot(p[slot], true, slot);
425 + const rest = cleanPresetSlot(p[slot], true, slot, isDefault);
426 if (hasModelIdentity(rest)) c[slot] = rest;
427 }
428 }
429 if (p.embedding) {
338 - const embedding = cleanPresetSlot(p.embedding, true, 'embedding');
430 + const embedding = cleanPresetSlot(p.embedding, true, 'embedding', isDefault);
431 if (hasModelIdentity(embedding)) c.embedding = embedding;
432 }
433 return c;
434 });
435 + const renames = presets
436 + .filter(p => p._originalName && p._originalName !== p.name)
437 + .map(p => ({ from: p._originalName, to: p.name }));
438 try {
344 - await fetchApi(`${API_BASE}/model_presets`, {
439 + const res = await fetchApi(`${API_BASE}/model_presets`, {
440 method: 'POST',
441 headers: { 'Content-Type': 'application/json' },
347 - body: JSON.stringify({ action: 'save', presets: clean })
442 + body: JSON.stringify({ action: 'save', presets: clean, renames })
443 });
349 - this.globalPresets = presets;
350 - this.switcherPresets = presets.filter(p => p.name);
351 - justToast('Presets saved');
444 + if (!res.ok) throw new Error(await res.text());
445 + const data = await res.json();
446 + this.globalPresets = this._normalizePresets(data.presets || clean);
447 + const savedNames = new Set(this.globalPresets.map(p => p.name));
448 + this._lastPresetReferenceChanges = Object.fromEntries([
449 + ...previousNames
450 + .filter(name => !savedNames.has(name))
451 + .map(name => [name.toLowerCase(), DEFAULT_PRESET_NAME]),
452 + ...renames.map(rename => [String(rename.from).toLowerCase(), rename.to]),
453 + ]);
454 + this.modelsSummaryPreset = this.remapPresetName(this.modelsSummaryPreset);
455 + this.switcherConfiguredPreset = this.remapPresetName(this.switcherConfiguredPreset);
456 + this.switcherEffectivePreset = this.remapPresetName(this.switcherEffectivePreset);
457 + this.switcherPresets = this.globalPresets.filter(p => p.name);
458 + globalThis.justToast?.('Presets saved', 'success');
459 + return this.globalPresets;
460 } catch (e) {
461 console.error('Failed to save global presets:', e);
354 - justToast('Failed to save presets');
462 + globalThis.justToast?.(e?.message || 'Failed to save presets', 'error');
463 + return false;
464 }
465 },
466
467 async resetGlobalPresets() {
468 + const previousNames = this.globalPresets.map(p => String(p.name || '')).filter(Boolean);
469 try {
470 const res = await fetchApi(`${API_BASE}/model_presets`, {
471 method: 'POST',
472 headers: { 'Content-Type': 'application/json' },
473 body: JSON.stringify({ action: 'reset' })
474 });
475 + if (!res.ok) throw new Error(await res.text());
476 const data = await res.json();
477 this.globalPresets = this._normalizePresets(data.presets);
478 + const savedNames = new Set(this.globalPresets.map(p => p.name));
479 + this._lastPresetReferenceChanges = Object.fromEntries(
480 + previousNames
481 + .filter(name => !savedNames.has(name))
482 + .map(name => [name.toLowerCase(), DEFAULT_PRESET_NAME])
483 + );
484 + this.modelsSummaryPreset = this.remapPresetName(this.modelsSummaryPreset);
485 + this.switcherConfiguredPreset = this.remapPresetName(this.switcherConfiguredPreset);
486 + this.switcherEffectivePreset = this.remapPresetName(this.switcherEffectivePreset);
487 this.switcherPresets = this.globalPresets.filter(p => p.name);
488 this._presetsLoaded = true;
489 return true;
@@ -376,9 +496,8 @@ export const store = createStore("modelConfig", {
496
497 /**
498 * Install hooks on the plugin settings context.
379 - * - Load/reset: initialize UI-only model fields when the settings object changes.
380 - * - Save: persist dirty API keys before the normal config save.
381 - * - Reset: reload global presets when settings are reset to defaults.
499 + * Keep the generic plugin settings modal selection-only across scope changes,
500 + * saves, and resets.
501 */
502 installSettingsHooks(context) {
503 if (!context || context.__modelConfigHooksInstalled) return;
@@ -394,16 +513,34 @@ export const store = createStore("modelConfig", {
513 };
514 }
515
397 - const originalSave = context.save.bind(context);
516 context.save = async () => {
517 context.error = null;
518 + this.syncContextConfigFields(context);
519 + context.isSaving = true;
520 try {
401 - await this.persistApiKeysForConfig(context.settings);
521 + const res = await fetchApi(`${API_BASE}/model_presets`, {
522 + method: 'POST',
523 + headers: { 'Content-Type': 'application/json' },
524 + body: JSON.stringify({
525 + action: 'select',
526 + name: context.settings.model_preset,
527 + project_name: context.projectName || '',
528 + agent_profile: context.agentProfileKey || '',
529 + }),
530 + });
531 + if (!res.ok) {
532 + context.error = await res.text() || 'Save failed';
533 + return;
534 + }
535 + context.settingsSnapshotJson = context._toComparableJson(context.settings);
536 + const contextId = window.Alpine?.store('chats')?.selected || '';
537 + if (contextId) await this.refreshSwitcher(contextId);
538 + window.closeModal?.();
539 } catch (e) {
403 - context.error = e?.message || 'Failed to save API keys.';
404 - return;
540 + context.error = e?.message || 'Save failed';
541 + } finally {
542 + context.isSaving = false;
543 }
406 - await originalSave();
544 };
545
546 const originalReset = context.resetToDefault.bind(context);
@@ -412,7 +549,6 @@ export const store = createStore("modelConfig", {
549 await originalReset();
550 if (context.settings !== before) {
551 this.syncContextConfigFields(context);
415 - await this.resetGlobalPresets();
552 }
553 };
554
@@ -466,12 +602,148 @@ export const store = createStore("modelConfig", {
602 return { matched, rest };
603 },
604
605 + presetModelRows(preset) {
606 + const chatP = this.chatProviders || [];
607 + const embedP = this.embeddingProviders || [];
608 + const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
609 + return [
610 + { icon: 'chat', title: 'Main', cfg: preset?.chat, pList: chatP },
611 + { icon: 'manufacturing', title: 'Utility', cfg: preset?.utility, pList: chatP },
612 + { icon: 'database', title: 'Embedding', cfg: preset?.embedding, pList: embedP },
613 + ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
614 + },
615 +
616 + getPreset(name) {
617 + return this.globalPresets.find(p => p.name === name)
618 + || this.globalPresets.find(p => p.name === DEFAULT_PRESET_NAME)
619 + || null;
620 + },
621 +
622 + remapPresetName(name) {
623 + const current = String(name || DEFAULT_PRESET_NAME);
624 + const renamed = this._lastPresetReferenceChanges[current.toLowerCase()];
625 + if (renamed && this.globalPresets.some(p => p.name === renamed)) return renamed;
626 + if (this.globalPresets.some(p => p.name === current)) return current;
627 + return DEFAULT_PRESET_NAME;
628 + },
629 +
630 + createScopedPresetSelector(context) {
631 + const store = this;
632 + return {
633 + context,
634 + loading: true,
635 + showScopedSettings: false,
636 + get presets() { return store.globalPresets; },
637 + get presetDescription() {
638 + const project = String(context?.projectName || '');
639 + const profile = String(context?.agentProfileKey || '');
640 + if (project && profile) return 'Default for this project and agent profile.';
641 + if (project) return 'Default for this project.';
642 + if (profile) return 'Default for this agent profile.';
643 + return 'Global default for all projects and agent profiles.';
644 + },
645 + get selectedPresetName() {
646 + return String(context?.settings?.model_preset || DEFAULT_PRESET_NAME);
647 + },
648 + get modelRows() { return store.presetModelRows(store.getPreset(this.selectedPresetName)); },
649 + async init() {
650 + this.loading = true;
651 + try {
652 + await store.ensureLoaded();
653 + await store.loadGlobalPresets();
654 + store.syncContextConfigFields(context, true);
655 + store.installSettingsHooks(context);
656 + } finally {
657 + this.loading = false;
658 + }
659 + },
660 + selectPreset(name) {
661 + context.settings.model_preset = name || DEFAULT_PRESET_NAME;
662 + },
663 + async editPresets() {
664 + await store.openPresetEditor(this.selectedPresetName);
665 + context.settings.model_preset = store.remapPresetName(this.selectedPresetName);
666 + },
667 + apiKeys() { return store.openApiKeysFromSummary(); },
668 + };
669 + },
670 +
671 + createGlobalPresetSelector() {
672 + const store = this;
673 + return {
674 + contextId: '',
675 + selectedPresetName: DEFAULT_PRESET_NAME,
676 + loading: true,
677 + presetDescription: 'Global default for new chats. Changing it also updates the open chat.',
678 + showScopedSettings: true,
679 + get presets() { return store.globalPresets; },
680 + get modelRows() { return store.presetModelRows(store.getPreset(this.selectedPresetName)); },
681 + async init(contextId = '') {
682 + this.contextId = String(contextId || '');
683 + this.loading = true;
684 + try {
685 + await store.ensureLoaded();
686 + const data = await store._fetchConfigData(
687 + this.contextId ? { context_id: this.contextId } : {}
688 + );
689 + store.globalPresets = store._normalizePresets(data.presets || []);
690 + store._presetsLoaded = true;
691 + this.selectedPresetName = data.selected_preset || data.configured_preset || DEFAULT_PRESET_NAME;
692 + } finally {
693 + this.loading = false;
694 + }
695 + },
696 + async selectPreset(name) {
697 + if (this.loading) return false;
698 + this.loading = true;
699 + try {
700 + const res = await fetchApi(`${API_BASE}/model_presets`, {
701 + method: 'POST',
702 + headers: { 'Content-Type': 'application/json' },
703 + body: JSON.stringify({
704 + action: 'select',
705 + name,
706 + context_id: this.contextId,
707 + }),
708 + });
709 + if (!res.ok) {
710 + const message = await res.text();
711 + globalThis.justToast?.(message || 'Failed to select model preset', 'error');
712 + return false;
713 + }
714 + const data = await res.json();
715 + this.selectedPresetName = data.selected_preset || name;
716 + store.modelsSummaryPreset = this.selectedPresetName;
717 + store.modelsSummary = this.modelRows;
718 + if (this.contextId) await store.refreshSwitcher(this.contextId);
719 + globalThis.justToast?.(`Model preset: ${this.selectedPresetName}`, 'success');
720 + return true;
721 + } catch (e) {
722 + console.error('Failed to select model preset:', e);
723 + globalThis.justToast?.(e?.message || 'Failed to select model preset', 'error');
724 + return false;
725 + } finally {
726 + this.loading = false;
727 + }
728 + },
729 + async editPresets() {
730 + await store.openPresetEditor(this.selectedPresetName, this.contextId);
731 + this.selectedPresetName = store.remapPresetName(this.selectedPresetName);
732 + },
733 + apiKeys() { return store.openApiKeysFromSummary(); },
734 + scopedSettings() { return store.openScopedPresetSettings(); },
735 + };
736 + },
737 +
738 // Model summary for agent-settings page
470 - async loadModelsSummary() {
471 - const data = await this._fetchConfigData();
739 + async loadModelsSummary(contextId = '') {
740 + await this.ensureLoaded();
741 + const data = await this._fetchConfigData(contextId ? { context_id: contextId } : {});
742 const cfg = data.config || {};
743 const chatP = data.chat_providers || [];
744 const embedP = data.embedding_providers || [];
745 + this.globalPresets = this._normalizePresets(data.presets || this.globalPresets);
746 + this.modelsSummaryPreset = data.selected_preset || data.configured_preset || DEFAULT_PRESET_NAME;
747 const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
748 return [
749 { icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
@@ -480,13 +752,13 @@ export const store = createStore("modelConfig", {
752 ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
753 },
754
483 - async refreshModelsSummary() {
755 + async refreshModelsSummary(contextId = '') {
756 if (this._modelsSummaryPromise) return await this._modelsSummaryPromise;
757
758 this.modelsSummaryLoading = true;
759 this._modelsSummaryPromise = (async () => {
760 try {
489 - const models = await this.loadModelsSummary();
761 + const models = await this.loadModelsSummary(contextId);
762 this.modelsSummary = models;
763 this._modelsSummaryLoaded = true;
764 return models;
@@ -512,6 +784,10 @@ export const store = createStore("modelConfig", {
784 },
785
786 async openConfigFromSummary() {
787 + return await this.openScopedPresetSettings();
788 + },
789 +
790 + async openScopedPresetSettings() {
791 try {
792 await pluginSettingsStore.openConfig('_model_config');
793 } finally {
@@ -520,7 +796,34 @@ export const store = createStore("modelConfig", {
796 },
797
798 async openPresetsFromSummary() {
523 - await window.openModal?.('/plugins/_model_config/webui/main.html');
799 + await this.openPresetEditor(this.modelsSummaryPreset);
800 + },
801 +
802 + async openPresetEditor(name = DEFAULT_PRESET_NAME, contextId = '') {
803 + this.presetEditorInitialName = name || DEFAULT_PRESET_NAME;
804 + this._lastPresetReferenceChanges = {};
805 + if (contextId) await this.refreshSwitcher(contextId);
806 + this._presetEditorSelectionPinned = true;
807 + try {
808 + await window.openModal?.('/plugins/_model_config/webui/main.html');
809 + } finally {
810 + this._presetEditorSelectionPinned = false;
811 + }
812 + },
813 +
814 + async preparePresetEditor(contextId = '') {
815 + if (this._presetEditorSelectionPinned) return;
816 + try {
817 + const data = await this._fetchConfigData(
818 + contextId ? { context_id: contextId } : {}
819 + );
820 + this.presetEditorInitialName = data.selected_preset
821 + || data.configured_preset
822 + || DEFAULT_PRESET_NAME;
823 + } catch (e) {
824 + console.error('Failed to resolve the active model preset:', e);
825 + this.presetEditorInitialName = DEFAULT_PRESET_NAME;
826 + }
827 },
828
829 async openApiKeysFromSummary() {
plugins/_model_config/webui/model-field.html
+1 -1
@@ -13,7 +13,7 @@
13 modelType — 'chat' | 'utility' | 'embedding'
14 providers — array of { value, label }
15 searchType — 'chat' | 'embedding'
16 - apiKeyMode — 'store' (config.html: key lives in $store.modelConfig.apiKeyValues) | 'inline' (preset: key lives in model.api_key)
16 + apiKeyMode — 'store' (key lives in $store.modelConfig.apiKeyValues), 'inline' (key lives in model.api_key), or 'none'
17 Optional:
18 providerFallback — fallback provider string for search/API key status (e.g. preset.chat.provider for utility slot)
19 apiBaseFallback — fallback api_base string for search (e.g. preset.chat.api_base for utility slot)
plugins/_model_config/webui/models-summary.html
+20 -117
@@ -1,145 +1,48 @@
1 <html>
2 -
2 <head>
4 - <title>Model Configuration</title>
5 -</head>
6 -
7 -<body>
3 + <title>Models</title>
4 <script type="module">
5 import { store } from "/plugins/_model_config/webui/model-config-store.js";
6 </script>
7 +</head>
8
9 +<body>
10 <div x-data>
11 <template x-if="$store.modelConfig">
12 <div>
15 - <div class="section-title">Model Configuration</div>
13 + <div class="section-title">Models</div>
14 <div class="section-description">
17 - AI models can be configured globally, per project, per agent and using quick-switch presets as well.
15 + Choose a global model preset. Project and agent-profile defaults are available in the full model settings.
16 </div>
17
20 - <!-- Read-only model config summary -->
21 - <div x-init="$store.modelConfig.refreshModelsSummary()" class="model-summary">
22 - <div class="model-summary-header">
23 - <div class="field-title">Current Models</div>
24 - <div class="field-description">Active model configuration resolved from global, project, and agent profile
25 - scopes.</div>
18 + <div class="settings-model-preset"
19 + x-data="$store.modelConfig.createGlobalPresetSelector()"
20 + x-init="await init($store.chats?.selected || '')">
21 + <div x-show="loading" class="settings-model-loading">
22 + <span class="material-symbols-outlined spinning">progress_activity</span>
23 + Loading model preset...
24 </div>
27 - <div x-show="$store.modelConfig.modelsSummaryLoading" style="text-align:center; padding:12px;">
28 - <span class="material-symbols-outlined spinning" style="font-size:18px;">progress_activity</span>
25 + <div x-show="!loading">
26 + <x-component path="/plugins/_model_config/webui/preset-overview.html"></x-component>
27 </div>
30 - <template x-if="!$store.modelConfig.modelsSummaryLoading && $store.modelConfig.modelsSummary.length">
31 - <div class="model-summary-grid">
32 - <template x-for="m in $store.modelConfig.modelsSummary" :key="m.title">
33 - <div class="model-summary-row">
34 - <span class="material-symbols-outlined model-summary-icon" x-text="m.icon"></span>
35 - <span class="model-summary-label" x-text="m.title"></span>
36 - <span class="model-summary-value">
37 - <span x-text="m.provider" style="opacity:0.6;"></span>
38 - <span style="opacity:0.3; margin:0 4px;">/</span>
39 - <span x-text="m.name"></span>
40 - </span>
41 - </div>
42 - </template>
43 - </div>
44 - </template>
45 - </div>
46 -
47 - <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;">
48 - <button class="btn btn-field" @click="$store.modelConfig.openConfigFromSummary()">
49 - Configure Models
50 - </button>
51 - <button class="btn btn-field" @click="$store.modelConfig.openPresetsFromSummary()">
52 - Configure Presets
53 - </button>
54 - <button class="btn btn-field" @click="$store.modelConfig.openApiKeysFromSummary()">
55 - Configure API Keys
56 - </button>
28 </div>
58 -
29 </div>
30 </template>
31 </div>
32
33 <style>
64 - .model-summary {
65 - margin-top: 14px;
66 - }
67 -
68 - .model-summary-header {
69 - margin-bottom: 8px;
70 - }
71 -
72 - .model-summary-grid {
73 - display: flex;
74 - flex-direction: column;
75 - background: var(--color-input);
76 - /* border: 1px solid var(--color-border);
77 - border-radius: 8px; */
78 - /* padding: 0.75em 1em; */
79 - max-width: 100%;
80 - overflow: hidden;
81 - box-sizing: border-box;
34 + .settings-model-preset {
35 + margin-top: 1rem;
36 }
37
84 - .model-summary-row {
38 + .settings-model-loading {
39 display: flex;
40 align-items: center;
87 - gap: 8px;
88 - padding: 9px 14px;
89 - font-size: 0.82rem;
90 - min-width: 0;
91 - }
92 -
93 - .model-summary-row:not(:last-child) {
94 - border-bottom: 1px solid var(--color-border);
95 - }
96 -
97 - .model-summary-icon {
98 - font-size: 16px;
99 - opacity: 0.45;
100 - flex-shrink: 0;
101 - }
102 -
103 - .model-summary-label {
104 - width: 80px;
105 - flex-shrink: 0;
106 - font-weight: 500;
107 - opacity: 0.7;
108 - }
109 -
110 - .model-summary-value {
111 - flex: 1;
112 - min-width: 0;
113 - overflow: hidden;
114 - text-overflow: ellipsis;
115 - white-space: nowrap;
116 - }
117 -
118 - @media (max-width: 600px) {
119 - .model-summary-grid {
120 - padding: 0.5em 0.5em;
121 - }
122 -
123 - .model-summary-row {
124 - flex-wrap: wrap;
125 - padding: 8px 8px;
126 - gap: 4px;
127 - }
128 -
129 - .model-summary-label {
130 - width: auto;
131 - }
132 -
133 - .model-summary-value {
134 - width: 100%;
135 - flex-basis: 100%;
136 - padding-left: 24px;
137 - font-size: 0.78rem;
138 - white-space: normal;
139 - word-break: break-all;
140 - }
41 + justify-content: center;
42 + gap: 0.5rem;
43 + min-height: 7rem;
44 + color: var(--color-text-secondary);
45 }
46 </style>
47 </body>
144 -
48 </html>
plugins/_model_config/webui/preset-overview.html new
+139
@@ -0,0 +1,139 @@
1 +<html>
2 +<body>
3 + <div class="model-preset-widget">
4 + <label class="model-preset-picker">
5 + <span>
6 + <span class="field-title">Model preset</span>
7 + <span class="field-description" x-text="presetDescription || 'Choose the models Agent Zero should use.'"></span>
8 + </span>
9 + <select :value="selectedPresetName"
10 + @change="selectPreset($el.value)"
11 + :disabled="loading">
12 + <template x-for="preset in presets" :key="preset.name">
13 + <option :value="preset.name" x-text="preset.name"></option>
14 + </template>
15 + </select>
16 + </label>
17 +
18 + <div class="model-preset-rows" aria-live="polite">
19 + <template x-for="model in modelRows" :key="model.title">
20 + <div class="model-preset-row">
21 + <span class="material-symbols-outlined model-preset-icon" x-text="model.icon"></span>
22 + <span class="model-preset-role" x-text="model.title"></span>
23 + <span class="model-preset-identity">
24 + <span class="model-preset-provider" x-text="model.provider"></span>
25 + <span class="model-preset-separator">/</span>
26 + <span x-text="model.name"></span>
27 + </span>
28 + </div>
29 + </template>
30 + </div>
31 +
32 + <div class="model-preset-actions">
33 + <button type="button" class="button" @click="editPresets()">
34 + <span class="icon material-symbols-outlined">tune</span>
35 + Edit Presets
36 + </button>
37 + <button type="button" class="button" @click="apiKeys()">
38 + <span class="icon material-symbols-outlined">key</span>
39 + API Keys
40 + </button>
41 + <button type="button" class="button" x-show="showScopedSettings" @click="scopedSettings()">
42 + <span class="icon material-symbols-outlined">account_tree</span>
43 + Per-project / agent
44 + </button>
45 + </div>
46 + </div>
47 +
48 + <style>
49 + .model-preset-widget {
50 + display: flex;
51 + flex-direction: column;
52 + gap: 0.85rem;
53 + }
54 +
55 + .model-preset-picker {
56 + display: grid;
57 + grid-template-columns: minmax(0, 1fr) minmax(13rem, 18rem);
58 + align-items: center;
59 + gap: 1rem;
60 + margin: 0;
61 + }
62 +
63 + .model-preset-picker select {
64 + width: 100%;
65 + min-width: 0;
66 + }
67 +
68 + .model-preset-rows {
69 + overflow: hidden;
70 + border: 1px solid var(--color-border);
71 + border-radius: 8px;
72 + background: var(--color-input);
73 + }
74 +
75 + .model-preset-row {
76 + display: grid;
77 + grid-template-columns: 1.25rem 5.5rem minmax(0, 1fr);
78 + align-items: center;
79 + gap: 0.5rem;
80 + min-width: 0;
81 + padding: 0.65rem 0.8rem;
82 + font-size: 0.82rem;
83 + }
84 +
85 + .model-preset-row + .model-preset-row {
86 + border-top: 1px solid var(--color-border);
87 + }
88 +
89 + .model-preset-icon,
90 + .model-preset-provider,
91 + .model-preset-separator {
92 + opacity: 0.5;
93 + }
94 +
95 + .model-preset-icon {
96 + font-size: 1rem;
97 + }
98 +
99 + .model-preset-role {
100 + font-weight: 600;
101 + opacity: 0.75;
102 + }
103 +
104 + .model-preset-identity {
105 + min-width: 0;
106 + overflow: hidden;
107 + text-overflow: ellipsis;
108 + white-space: nowrap;
109 + }
110 +
111 + .model-preset-separator {
112 + margin: 0 0.25rem;
113 + }
114 +
115 + .model-preset-actions {
116 + display: flex;
117 + flex-wrap: wrap;
118 + gap: 0.5rem;
119 + }
120 +
121 + @media (max-width: 640px) {
122 + .model-preset-picker {
123 + grid-template-columns: 1fr;
124 + gap: 0.5rem;
125 + }
126 +
127 + .model-preset-row {
128 + grid-template-columns: 1.25rem 4.5rem minmax(0, 1fr);
129 + padding: 0.6rem;
130 + }
131 +
132 + .model-preset-actions .button {
133 + flex: 1 1 auto;
134 + justify-content: center;
135 + }
136 + }
137 + </style>
138 +</body>
139 +</html>
plugins/_model_config/webui/switcher-mixin.js
+43 -20
@@ -13,15 +13,18 @@ function normalizeModelIdentity(value) {
13 return { provider, name };
14 }
15
16 -function formatModelIdentity(value) {
17 - if (!value) return "";
18 - if (value.provider && value.name) return `${value.provider}/${value.name}`;
19 - return value.name || value.provider || "";
16 +export function getModelLeafName(value) {
17 + const name = String(typeof value === "string" ? value : value?.name || "").trim();
18 + if (!name) return "";
19 + const leaf = name.slice(name.lastIndexOf("/") + 1).trim();
20 + return leaf || name;
21 }
22
23 export const switcherState = {
24 switcherAllowed: false,
25 switcherOverride: null,
26 + switcherConfiguredPreset: "Default",
27 + switcherEffectivePreset: "Default",
28 switcherPresets: [],
29 switcherLoading: true,
30 agentProfiles: [],
@@ -58,7 +61,7 @@ export const switcherMethods = {
61 },
62
63 async loadSwitcherState(contextId) {
61 - const result = { allowed: false, presets: [], override: null };
64 + const result = { allowed: false, presets: [], override: null, configuredPreset: "Default", effectivePreset: "Default" };
65 try {
66 await this.loadGlobalPresets();
67 result.presets = this.globalPresets.filter(p => p.name);
@@ -71,6 +74,8 @@ export const switcherMethods = {
74 const overData = await overRes.json();
75 result.allowed = !!overData.allowed;
76 result.override = overData.override || null;
77 + result.configuredPreset = overData.configured_preset || "Default";
78 + result.effectivePreset = overData.effective_preset || result.configuredPreset;
79 }
80 } catch (e) {
81 console.error("Model switcher load failed:", e);
@@ -85,7 +90,8 @@ export const switcherMethods = {
90 headers: { "Content-Type": "application/json" },
91 body: JSON.stringify({ action: "set_preset", context_id: contextId, preset_name: presetName }),
92 });
88 - return !!(await res.json()).ok;
93 + const data = await res.json();
94 + return data?.ok ? data : null;
95 } catch (e) {
96 console.error("Failed to set preset override:", e);
97 return false;
@@ -99,7 +105,8 @@ export const switcherMethods = {
105 headers: { "Content-Type": "application/json" },
106 body: JSON.stringify({ action: "clear", context_id: contextId }),
107 });
102 - return !!(await res.json()).ok;
108 + const data = await res.json();
109 + return data?.ok ? data : null;
110 } catch (e) {
111 console.error("Failed to clear override:", e);
112 return false;
@@ -178,6 +185,7 @@ export const switcherMethods = {
185 selectedContext.agent_profile = data.agent_profile || agentProfile;
186 selectedContext.agent_profile_label = label;
187 }
188 + await this.refreshSwitcher(contextId);
189 window.justToast?.(`Agent profile: ${label}`, "success", 1600, "agent-profile-switch");
190 return true;
191 } catch (e) {
@@ -208,6 +216,8 @@ export const switcherMethods = {
216 this.switcherAllowed = state.allowed;
217 this.switcherPresets = state.presets;
218 this.switcherOverride = state.override;
219 + this.switcherConfiguredPreset = state.configuredPreset;
220 + this.switcherEffectivePreset = state.effectivePreset;
221 } catch (e) {
222 console.error('Model switcher refresh failed:', e);
223 } finally {
@@ -216,30 +226,41 @@ export const switcherMethods = {
226 },
227
228 async selectPresetSwitch(contextId, presetName) {
219 - const ok = await this.setPresetOverride(contextId, presetName);
220 - if (ok) this.switcherOverride = { preset_name: presetName };
221 - return ok;
229 + const data = await this.setPresetOverride(contextId, presetName);
230 + if (data) {
231 + const selected = data.preset_name || presetName;
232 + this.switcherOverride = { preset_name: selected };
233 + this.switcherEffectivePreset = selected;
234 + }
235 + return !!data;
236 },
237
238 async clearOverrideSwitch(contextId) {
225 - const ok = await this.clearOverride(contextId);
226 - if (ok) this.switcherOverride = null;
227 - return ok;
239 + const data = await this.clearOverride(contextId);
240 + if (data) {
241 + this.switcherOverride = null;
242 + this.switcherEffectivePreset = data.effective_preset || this.switcherConfiguredPreset || 'Default';
243 + }
244 + return !!data;
245 },
246
247 getSwitcherLabel() {
248 const o = this.switcherOverride;
232 - if (!o) return 'Default LLM';
233 - if (o.preset_name) return o.preset_name;
249 + const presetName = this.switcherEffectivePreset || o?.preset_name || 'Default';
250 + const preset = this.getActivePreset();
251 + if (preset) {
252 + const mainModelName = getModelLeafName(preset.chat);
253 + return mainModelName ? `${presetName} ${mainModelName}` : presetName;
254 + }
255 + if (!o || o.preset_name) return presetName;
256
257 const models = this.getCustomOverrideModels();
236 - return formatModelIdentity(models.main) || formatModelIdentity(models.utility) || o.name || o.provider || 'Custom';
258 + const mainModelName = getModelLeafName(models.main);
259 + return mainModelName ? `Custom ${mainModelName}` : 'Custom';
260 },
261
262 getActivePreset() {
240 - const o = this.switcherOverride;
241 - if (!o || !o.preset_name) return null;
242 - return this.switcherPresets.find(p => p.name === o.preset_name) || null;
263 + return this.switcherPresets.find(p => p.name === this.switcherEffectivePreset) || null;
264 },
265
266 getActiveModels() {
@@ -248,6 +269,7 @@ export const switcherMethods = {
269 return {
270 main: normalizeModelIdentity(preset.chat),
271 utility: normalizeModelIdentity(preset.utility),
272 + embedding: normalizeModelIdentity(preset.embedding),
273 };
274 }
275 return this.getCustomOverrideModels();
@@ -255,10 +277,11 @@ export const switcherMethods = {
277
278 getCustomOverrideModels() {
279 const o = this.switcherOverride;
258 - if (!o || o.preset_name) return { main: null, utility: null };
280 + if (!o || o.preset_name) return { main: null, utility: null, embedding: null };
281 return {
282 main: normalizeModelIdentity(o.chat || o),
283 utility: normalizeModelIdentity(o.utility),
284 + embedding: normalizeModelIdentity(o.embedding),
285 };
286 },
287 };
plugins/_onboarding/AGENTS.md
+1
@@ -14,6 +14,7 @@
14 - Keep onboarding provider choices synchronized with model configuration and provider metadata.
15 - Do not ask for or expose API keys outside approved settings/API flows.
16 - Avoid blocking already-configured users with unnecessary onboarding prompts.
17 +- Completion updates the selected global model preset (normally immutable-name `Default`); advanced setup opens that preset in the shared editor.
18
19 ## Work Guidance
20
plugins/_onboarding/webui/onboarding-store.js
+3 -2
@@ -860,7 +860,8 @@ export const store = createStore("onboarding", {
860
861 async openAdvancedSettings() {
862 window.closeModal?.();
863 - const { store: pluginSettingsStore } = await import("/components/plugins/plugin-settings-store.js");
864 - await pluginSettingsStore.openConfig("_model_config");
863 + await modelConfigStore.openPresetEditor(
864 + this.config?.model_preset || "Default"
865 + );
866 },
867 });
plugins/_telegram_integration/AGENTS.md
+1
@@ -17,6 +17,7 @@
17 - Keep allowed-user, group-mode, project, model, and `/send` controls enforced.
18 - Install Telegram dependencies into the framework runtime only when required.
19 - Agent profile picker actions change the top-level chat profile and must preserve existing subordinate agent profiles.
20 +- Model picker status shows the effective preset; clearing a chat override returns to its scoped preset rather than assuming `Default`.
21
22 ## Work Guidance
23
plugins/_telegram_integration/helpers/command_ui.py
+4 -3
@@ -279,13 +279,14 @@ def _model_view(
279 ]
280 current = context.get_data("chat_model_override")
281 current_name = current.get("preset_name") if isinstance(current, dict) else ""
282 - status = f"Current model: <b>{_html(current_name or 'Default')}</b>"
282 + effective_name = model_config.get_effective_preset_name(context.agent0)
283 + status = f"Current model: <b>{_html(effective_name)}</b>"
284 if not model_config.is_chat_override_allowed(context.agent0):
285 return status + "\nPer-chat model switching is disabled.", None
286 if selected:
287 status = "Model updated.\n" + status
287 - rows = _paged_buttons("model", presets, page, current_name)
288 - rows.append([{"text": "Default", "callback_data": "tg:model:clear"}])
288 + rows = _paged_buttons("model", presets, page, effective_name)
289 + rows.append([{"text": "Use scoped preset", "callback_data": "tg:model:clear"}])
290 return status, {"inline_keyboard": rows}
291
292
skills/a0-create-agent/AGENTS.md
+1
@@ -14,6 +14,7 @@
14 - Default new user profiles to `usr/agents/`, not bundled `agents/`.
15 - Keep `agent.yaml`, prompt inheritance, tools, and extension guidance synchronized with profile loader behavior.
16 - Do not encourage storing secrets or provider credentials in profiles.
17 +- Optional profile `_model_config/config.json` files contain only an existing global `model_preset` selection; never generate copied model dictionaries or profile-local preset definitions.
18
19 ## Work Guidance
20
skills/a0-create-agent/SKILL.md
+16 -51
@@ -72,7 +72,7 @@ Recommended interview flow:
72 - "Should it work more like a concise specialist, a rigorous researcher, a code-heavy implementer, or something else?"
73 4. Ask about advanced options only when the user's request implies them:
74 - custom output format
75 - - profile-specific Main/Utility models
75 + - a profile-specific model preset
76 - custom tools
77 - lifecycle extensions
78 - plugin/project scope
@@ -141,14 +141,12 @@ Before writing files, produce exactly one valid JSON object in a fenced `json` b
141 "llm_config": {
142 "enabled": false,
143 "path": "/a0/usr/agents/data-analyst/plugins/_model_config/config.json",
144 - "source": "inherit_global",
145 - "chat_model": null,
146 - "utility_model": null,
147 - "embedding_model": null,
144 + "source": "inherit_scoped",
145 + "model_preset": null,
146 "notes": [
147 "Do not put model settings in agent.yaml.",
150 - "Only create this config file if the user wants profile-specific Main or Utility models.",
151 - "Scoped _model_config config is not deep-merged; include a complete config with chat_model, utility_model, and embedding_model."
148 + "Only create this config file if the user wants this profile to select a specific global model preset.",
149 + "Scoped _model_config config stores only model_preset."
150 ]
151 },
152 "files": [
@@ -183,7 +181,7 @@ Rules for the blueprint:
181 - `profile.name` must be lowercase letters, numbers, hyphens, or underscores only.
182 - `prompt_strategy.root_prompt_overrides` must list any inherited root `/prompts` files being considered or replaced and why.
183 - If a root prompt override is only a possibility, list it in `prompt_strategy` but do not add it to `files` until confirmed.
186 -- `llm_config.enabled` controls whether a profile-scoped `_model_config/config.json` file is created. Keep it `false` when models should inherit from global/project settings.
184 +- `llm_config.enabled` controls whether a profile-scoped `_model_config/config.json` file is created. Keep it `false` when the preset should inherit from broader project/global settings.
185 - `files[*].content` must be the exact file content to write.
186 - Include only files that should actually be created.
187 - Set `status` to `draft` until all required choices are known; set it to `ready` only after resolving open questions.
@@ -203,7 +201,7 @@ The blueprint must contain these required profile inputs:
201 | **context** | instructions telling the *superior* agent when to delegate to this profile | `Use this agent for data analysis tasks, creating visualizations, statistical analysis, and working with datasets in Python.` |
202
203 > [!NOTE]
206 -> `agent.yaml` has **only** these three content fields (`title`, `description`, `context`). Do not add model, temperature, or `allowed_tools` fields to `agent.yaml`. Profile-specific model settings live in a companion `_model_config` plugin config file, and tool availability is controlled by plugin activation.
204 +> `agent.yaml` has **only** these three content fields (`title`, `description`, `context`). Do not add model, temperature, or `allowed_tools` fields to `agent.yaml`. A profile-specific preset selection lives in a companion `_model_config` plugin config file, and tool availability is controlled by plugin activation.
205
206 ---
207
@@ -232,9 +230,9 @@ A profile with only `agent.yaml` is valid — it inherits everything from `defau
230
231 ---
232
235 -## Step 4: Optional profile-specific LLM config
233 +## Step 4: Optional profile-specific model preset
234
237 -Agent Zero does **not** read Main/Utility model settings from `agent.yaml`. The `_model_config` plugin is always enabled and supports per-agent-profile config. If the user wants this profile to use specific LLMs, create a companion config file:
235 +Agent Zero does **not** read model settings from `agent.yaml`. The `_model_config` plugin is always enabled and supports per-agent-profile preset selection. If the user wants this profile to use a specific existing global preset, create a companion config file:
236
237 | Profile scope | Model config path |
238 |---|---|
@@ -242,54 +240,21 @@ Agent Zero does **not** read Main/Utility model settings from `agent.yaml`. The
240 | Plugin-distributed profile | `/a0/usr/plugins/<plugin>/agents/<profile>/plugins/_model_config/config.json` |
241 | Project-scoped profile | `<project>/.a0proj/agents/<profile>/plugins/_model_config/config.json` |
242
245 -Scoped `_model_config/config.json` files are selected as a whole; they are **not** deep-merged with broader global/project config. Therefore, if you create this file, include a complete effective model config. When the user only wants to customize Main or Utility, copy the other sections from the current effective config.
243 +Scoped `_model_config/config.json` files contain only the selected global preset name. Preset definitions are managed centrally in Model Configuration and are not copied into profiles.
244
247 -Minimal complete profile-scoped config:
245 +Profile-scoped selection:
246
247 ```json
248 {
251 - "allow_chat_override": true,
252 - "chat_model": {
253 - "provider": "openrouter",
254 - "name": "anthropic/claude-sonnet-4.6",
255 - "api_base": "",
256 - "ctx_length": 200000,
257 - "ctx_history": 0.7,
258 - "vision": true,
259 - "rl_requests": 0,
260 - "rl_input": 0,
261 - "rl_output": 0,
262 - "kwargs": {}
263 - },
264 - "utility_model": {
265 - "provider": "openrouter",
266 - "name": "openai/gpt-5.4-mini",
267 - "api_base": "",
268 - "ctx_length": 128000,
269 - "ctx_input": 0.7,
270 - "rl_requests": 0,
271 - "rl_input": 0,
272 - "rl_output": 0,
273 - "kwargs": {}
274 - },
275 - "embedding_model": {
276 - "provider": "huggingface",
277 - "name": "sentence-transformers/all-MiniLM-L6-v2",
278 - "api_base": "",
279 - "rl_requests": 0,
280 - "rl_input": 0,
281 - "kwargs": {}
282 - }
249 + "model_preset": "Research"
250 }
251 ```
252
253 Rules:
254
288 -- Ask the user whether Main/Utility should inherit defaults or be profile-specific.
289 -- Do not store API keys in this file; API keys are managed globally through settings/secrets.
290 -- Include `chat_model`, `utility_model`, and `embedding_model` in the generated file if any profile-scoped model config is created.
291 -- If only Main or Utility is customized, copy the non-customized model sections from the current effective `_model_config` config.
292 -- Use provider IDs and model names exactly as `_model_config` expects.
255 +- Ask whether the profile should inherit its scoped preset or select an existing global preset.
256 +- Verify the preset exists before adding the file. If new model choices are needed, create a global preset through Model Configuration first.
257 +- Do not store API keys or model dictionaries in this file; API keys and preset definitions are managed centrally.
258 - Add this file to `files` only when `llm_config.enabled` is `true`.
259
260 ---
@@ -446,7 +411,7 @@ Copy this shape when in doubt — it demonstrates every customization surface a
411
412 - [ ] Confirmed profile scope (user / plugin / project)
413 - [ ] Produced and confirmed `agent_zero.agent_profile_blueprint.v1` JSON
449 -- [ ] Confirmed whether Main/Utility models inherit defaults or need `_model_config/config.json`
414 +- [ ] Confirmed whether the model preset inherits or needs a profile-specific `_model_config/config.json` selection
415 - [ ] Directory name is unique and matches allowed characters
416 - [ ] `agent.yaml` contains exactly `title`, `description`, `context`
417 - [ ] Prompt overrides only include files that actually change behavior
tests/test_model_config_api_keys.py
+37 -9
@@ -107,7 +107,7 @@ async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
107 assert "onboarding-banner-btn-container" not in row["html"]
108
109
110 -def test_model_config_frontend_tracks_inline_api_key_edits():
110 +def test_model_config_frontend_tracks_provider_api_key_edits():
111 store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
112 api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js"
113 model_gate_path = PROJECT_ROOT / "webui" / "components" / "chat" / "model-gate-store.js"
@@ -121,10 +121,15 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
121 + api_keys_mixin_path.read_text(encoding="utf-8")
122 )
123 model_gate_content = model_gate_path.read_text(encoding="utf-8")
124 + preset_modal_content = (
125 + PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "main.html"
126 + ).read_text(encoding="utf-8")
127 config_content = (
128 config_path.read_text(encoding="utf-8")
129 + "\n"
130 + model_field_path.read_text(encoding="utf-8")
131 + + "\n"
132 + + preset_modal_content
133 )
134 modal_content = modal_path.read_text(encoding="utf-8")
135
@@ -135,8 +140,12 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
140 assert 'callJsonApi("/plugins/_model_config/model_config_get"' in model_gate_content
141 assert "dispatchPendingIfConfigured()" in model_gate_content
142 assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
138 - assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
143 assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
144 + assert "apiKeyMode: 'none'" not in preset_modal_content
145 + assert preset_modal_content.count("apiKeyMode: 'store'") == 3
146 + assert "$store.modelConfig.resetApiKeyDrafts();" in preset_modal_content
147 + assert "await $store.modelConfig.refreshApiKeyStatus();" in preset_modal_content
148 + assert "await store.persistAllDirtyApiKeys();" in store_content
149 assert "persistAllDirtyApiKeys()" in modal_content
150 assert "$store.modelConfig.resetApiKeyDrafts();" in modal_content
151
@@ -166,14 +175,30 @@ def test_model_switcher_frontend_renders_custom_overrides():
175 )
176
177 switcher_content = switcher_path.read_text(encoding="utf-8")
178 + switcher_html = (
179 + PROJECT_ROOT
180 + / "plugins"
181 + / "_model_config"
182 + / "extensions"
183 + / "webui"
184 + / "chat-input-progress-start"
185 + / "model-switcher.html"
186 + ).read_text(encoding="utf-8")
187 refresh_extension_content = refresh_extension_path.read_text(encoding="utf-8")
188
189 assert "function normalizeModelIdentity(value)" in switcher_content
172 - assert "formatModelIdentity(models.main)" in switcher_content
173 - assert "formatModelIdentity(models.utility)" in switcher_content
190 + assert "export function getModelLeafName(value)" in switcher_content
191 + assert 'name.lastIndexOf("/") + 1' in switcher_content
192 + assert "`${presetName} ${mainModelName}`" in switcher_content
193 + assert "formatModelIdentity(models.utility)" not in switcher_content
194 assert "normalizeModelIdentity(o.chat || o)" in switcher_content
195 assert "normalizeModelIdentity(o.utility)" in switcher_content
196 + assert "$store.modelConfig.getSwitcherLabel()" in switcher_html
197 + assert "model-switcher-active-pills" not in switcher_html
198 + assert "model-pill-role" not in switcher_html
199 assert "_model_config_override_revision" in refresh_extension_content
200 + assert "activeContext?.agent_profile" in refresh_extension_content
201 + assert "activeContext?.project" in refresh_extension_content
202 assert "modelConfigStore.refreshSwitcher(contextId)" in refresh_extension_content
203
204
@@ -385,12 +410,15 @@ def test_model_config_migration_repairs_saved_venice_user_slots(monkeypatch, tmp
410 config = json.loads(config_path.read_text(encoding="utf-8"))
411 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
412
388 - assert config["chat_model"]["kwargs"] == expected
389 - assert config["embedding_model"]["kwargs"] == expected
390 - assert config["utility_model"]["kwargs"] == {"a0_api_mode": "responses"}
413 + assert config == {"model_preset": "Default"}
414 + assert presets[0]["name"] == "Default"
415 assert presets[0]["chat"]["kwargs"] == expected
392 - assert presets[0]["utility"]["kwargs"] == {"keep": True}
393 - assert presets[1]["kwargs"] == expected
416 + assert presets[0]["embedding"]["kwargs"] == expected
417 + assert presets[0]["utility"]["kwargs"] == {"a0_api_mode": "responses"}
418 + assert presets[1]["chat"]["kwargs"] == expected
419 + assert presets[1]["utility"]["kwargs"] == {"keep": True}
420 + assert presets[2]["chat"]["kwargs"] == expected
421 + assert (plugin_dir / "config.json.pre-unified-presets.bak").exists()
422
423
424 def test_local_chat_providers_default_to_chat_completions():
tests/test_model_config_project_presets.py
+528 -136
@@ -7,6 +7,7 @@ import types
7 from pathlib import Path
8
9 import pytest
10 +import yaml
11 from flask import Flask
12
13
@@ -79,30 +80,29 @@ def _prepare_a0_tree(monkeypatch, tmp_path: Path):
80 "name: _model_config\nper_project_config: true\nper_agent_config: true\n",
81 encoding="utf-8",
82 )
82 - (plugin_dir / "default_presets.yaml").write_text(
83 + fallback_path = plugin_dir / "mode_presets_fallback.yaml"
84 + fallback_path.write_text(
85 """
84 -- name: Default Balance
86 +- name: Default
87 chat:
88 provider: openrouter
89 name: default-chat
90 utility:
91 provider: openrouter
92 name: default-utility
93 + embedding:
94 + provider: huggingface
95 + name: default-embedding
96 +- name: Balance
97 + chat:
98 + provider: openrouter
99 + name: balanced-chat
100 """.lstrip(),
101 encoding="utf-8",
102 )
103 (plugin_dir / "default_config.yaml").write_text(
104 """
96 -allow_chat_override: true
97 -chat_model:
98 - provider: openrouter
99 - name: configured-chat
100 -utility_model:
101 - provider: openrouter
102 - name: configured-utility
103 -embedding_model:
104 - provider: huggingface
105 - name: configured-embedding
105 +model_preset: Default
106 """.lstrip(),
107 encoding="utf-8",
108 )
@@ -242,15 +242,16 @@ class ProjectConflictLoader(Extension):
242 _clear_runtime_caches()
243
244
245 -def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_path):
245 +def test_global_presets_require_immutable_default_and_save_behavior(monkeypatch, tmp_path):
246 _prepare_a0_tree(monkeypatch, tmp_path)
247
248 from plugins._model_config.helpers import model_config
249
250 - assert model_config.get_presets()[0]["name"] == "Default Balance"
250 + assert model_config.get_presets()[0]["name"] == "Default"
251
252 model_config.save_presets(
253 [
254 + model_config.get_presets()[0],
255 {
256 "name": "Global One",
257 "scope": "project",
@@ -261,63 +262,126 @@ def test_global_presets_keep_legacy_default_and_save_behavior(monkeypatch, tmp_p
262 )
263
264 presets = model_config.get_presets()
264 - assert presets == [
265 - {"name": "Global One", "chat": {"provider": "openai", "name": "gpt-test"}}
266 - ]
265 + assert presets[0]["name"] == "Default"
266 + assert presets[1] == {
267 + "name": "Global One",
268 + "chat": {"provider": "openai", "name": "gpt-test"},
269 + }
270
271 saved_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
272 assert "scope:" not in saved_path.read_text(encoding="utf-8")
273
271 - model_config.save_presets([])
272 - assert model_config.get_presets() == []
274 + with pytest.raises(ValueError, match="cannot be deleted or renamed"):
275 + model_config.save_presets([])
276
274 - assert model_config.reset_presets()[0]["name"] == "Default Balance"
277 + assert model_config.reset_presets()[0]["name"] == "Default"
278
279
277 -def test_project_presets_are_separate_and_resolve_by_scope(monkeypatch, tmp_path):
280 +def test_project_scope_selects_global_presets_only(monkeypatch, tmp_path):
281 _prepare_a0_tree(monkeypatch, tmp_path)
282
280 - from helpers import projects
283 + from helpers import projects, plugins
284 from plugins._model_config.helpers import model_config
285
286 projects.create_project("demo", {"title": "Demo"})
284 - model_config.save_presets(
285 - [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
286 - )
287 - model_config.save_presets(
288 - [{"name": "Shared", "chat": {"provider": "project", "name": "chat"}}],
289 - project_name="demo",
290 - )
287 + plugins.save_plugin_config("_model_config", "demo", "", {"model_preset": "Balance"})
288
292 - assert model_config.get_presets()[0]["chat"]["provider"] == "global"
293 - assert model_config.get_project_presets("demo")[0]["chat"]["provider"] == "project"
289 + assert model_config.get_configured_preset_name(project_name="demo") == "Balance"
290 + assert model_config.resolve_preset("Balance", scope="global")["chat"]["name"] == "balanced-chat"
291 + assert model_config.resolve_preset("Balance", scope="project", project_name="demo") is None
292 + with pytest.raises(ValueError, match="no longer supported"):
293 + model_config.save_presets(model_config.get_presets(), project_name="demo")
294
295 - combined = model_config.get_combined_presets("demo")
296 - assert [(item["scope"], item["project_name"], item["name"]) for item in combined] == [
297 - ("global", "", "Shared"),
298 - ("project", "demo", "Shared"),
299 - ]
300 - assert model_config.resolve_preset("Shared", scope="global")["chat"]["provider"] == "global"
301 - assert (
302 - model_config.resolve_preset("Shared", scope="project", project_name="demo")["chat"]["provider"]
303 - == "project"
295 +
296 +def test_selected_preset_resolves_complete_runtime_config_from_default(monkeypatch, tmp_path):
297 + _prepare_a0_tree(monkeypatch, tmp_path)
298 +
299 + from helpers import plugins
300 + from plugins._model_config.helpers import model_config
301 +
302 + plugins.save_plugin_config("_model_config", "", "", {"model_preset": "Balance"})
303 + config = model_config.get_config()
304 +
305 + assert config["model_preset"] == "Balance"
306 + assert config["chat_model"]["name"] == "balanced-chat"
307 + assert config["utility_model"]["name"] == "default-utility"
308 + assert config["embedding_model"]["name"] == "default-embedding"
309 + assert config["allow_chat_override"] is True
310 +
311 +
312 +def test_legacy_raw_preset_is_preserved_as_canonical_chat_slot(monkeypatch, tmp_path):
313 + _prepare_a0_tree(monkeypatch, tmp_path)
314 +
315 + from plugins._model_config.helpers import model_config
316 +
317 + cleaned = model_config.clean_presets_for_file(
318 + [
319 + {
320 + "name": "Legacy",
321 + "provider": "venice",
322 + "api_key": "must-not-persist",
323 + "kwargs": {"temperature": 0.2},
324 + }
325 + ]
326 )
327
328 + assert cleaned == [
329 + {
330 + "name": "Legacy",
331 + "chat": {
332 + "provider": "venice",
333 + "kwargs": {"temperature": 0.2},
334 + "name": "Legacy",
335 + },
336 + }
337 + ]
338
307 -def test_bundled_utility_presets_inherit_advanced_settings():
308 - import yaml
339
310 - presets_path = PROJECT_ROOT / "plugins" / "_model_config" / "default_presets.yaml"
311 - presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
340 +def test_fallback_non_default_utility_presets_inherit_advanced_settings():
341 + from plugins._model_config.helpers import model_config
342 +
343 + presets_path = (
344 + PROJECT_ROOT / "plugins" / "_model_config" / "mode_presets_fallback.yaml"
345 + )
346 + presets = model_config.parse_preset_collection(
347 + presets_path.read_text(encoding="utf-8")
348 + )
349 +
350 + assert {
351 + preset["name"]: (
352 + preset["chat"]["name"],
353 + preset["utility"]["name"],
354 + preset["chat"]["vision"],
355 + )
356 + for preset in presets
357 + } == {
358 + "Default": (
359 + "openai/gpt-5.6-terra",
360 + "google/gemini-3.1-flash-lite",
361 + True,
362 + ),
363 + "Efficiency": (
364 + "z-ai/glm-5.2",
365 + "deepseek/deepseek-v4-flash",
366 + False,
367 + ),
368 + "Power": (
369 + "openai/gpt-5.6-sol",
370 + "openai/gpt-5.6-luna",
371 + True,
372 + ),
373 + }
374
375 for preset in presets:
376 + if preset.get("name") == "Default":
377 + continue
378 utility = preset.get("utility") or {}
379 assert "ctx_length" not in utility
380 assert "ctx_input" not in utility
381
382
383 @pytest.mark.asyncio
320 -async def test_model_presets_api_returns_global_or_combined_by_project(monkeypatch, tmp_path):
384 +async def test_model_presets_api_returns_global_presets_for_project_scope(monkeypatch, tmp_path):
385 _prepare_a0_tree(monkeypatch, tmp_path)
386
387 from helpers import projects
@@ -325,29 +389,405 @@ async def test_model_presets_api_returns_global_or_combined_by_project(monkeypat
389 from plugins._model_config.helpers import model_config
390
391 projects.create_project("demo", {"title": "Demo"})
328 - model_config.save_presets(
329 - [{"name": "Global", "chat": {"provider": "global", "name": "chat"}}]
330 - )
331 - model_config.save_presets(
332 - [{"name": "Project", "chat": {"provider": "project", "name": "chat"}}],
333 - project_name="demo",
334 - )
392 + presets = model_config.get_presets()
393 + presets.append({"name": "Global", "chat": {"provider": "global", "name": "chat"}})
394 + model_config.save_presets(presets)
395
396 handler = ModelPresets(Flask(__name__), threading.Lock())
397 global_response = await handler.process({"action": "get"}, None)
338 - assert global_response["presets"] == [
339 - {"name": "Global", "chat": {"provider": "global", "name": "chat"}}
398 + assert [preset["name"] for preset in global_response["presets"]] == [
399 + "Default",
400 + "Balance",
401 + "Global",
402 ]
341 - assert "scope" not in global_response["presets"][0]
403
404 project_response = await handler.process({"action": "get", "project_name": "demo"}, None)
344 - assert [(p["scope"], p["name"]) for p in project_response["presets"]] == [
345 - ("global", "Global"),
346 - ("project", "Project"),
405 + assert [p["name"] for p in project_response["presets"]] == [
406 + "Default",
407 + "Balance",
408 + "Global",
409 + ]
410 + assert project_response["project_presets"] == []
411 +
412 +
413 +@pytest.mark.asyncio
414 +async def test_model_presets_api_saves_only_scoped_selection(monkeypatch, tmp_path):
415 + _prepare_a0_tree(monkeypatch, tmp_path)
416 +
417 + from helpers import projects
418 + from plugins._model_config.api.model_presets import ModelPresets
419 +
420 + projects.create_project("demo", {"title": "Demo"})
421 + handler = ModelPresets(Flask(__name__), threading.Lock())
422 +
423 + response = await handler.process(
424 + {
425 + "action": "select",
426 + "name": "Balance",
427 + "project_name": "demo",
428 + },
429 + None,
430 + )
431 +
432 + assert response == {"ok": True, "selected_preset": "Balance"}
433 + config_path = (
434 + tmp_path
435 + / "usr"
436 + / "projects"
437 + / "demo"
438 + / ".a0proj"
439 + / "plugins"
440 + / "_model_config"
441 + / "config.json"
442 + )
443 + assert json.loads(config_path.read_text(encoding="utf-8")) == {
444 + "model_preset": "Balance"
445 + }
446 +
447 +
448 +def test_unified_preset_migration_preserves_global_and_scoped_configs(monkeypatch, tmp_path):
449 + _prepare_a0_tree(monkeypatch, tmp_path)
450 +
451 + from helpers import projects
452 + from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
453 + MigrateModelConfig,
454 + )
455 +
456 + global_dir = tmp_path / "usr" / "plugins" / "_model_config"
457 + global_dir.mkdir(parents=True, exist_ok=True)
458 + global_config = {
459 + "chat_model": {"provider": "openrouter", "name": "legacy-global-chat"},
460 + "utility_model": {"provider": "openrouter", "name": "legacy-global-utility"},
461 + "embedding_model": {"provider": "huggingface", "name": "legacy-global-embedding"},
462 + }
463 + (global_dir / "config.json").write_text(json.dumps(global_config), encoding="utf-8")
464 + (global_dir / "presets.yaml").write_text(
465 + "- name: Existing\n chat:\n provider: openrouter\n name: existing-chat\n",
466 + encoding="utf-8",
467 + )
468 +
469 + projects.create_project("demo", {"title": "Demo"})
470 + project_config_path = (
471 + tmp_path
472 + / "usr"
473 + / "projects"
474 + / "demo"
475 + / ".a0proj"
476 + / "plugins"
477 + / "_model_config"
478 + / "config.json"
479 + )
480 + project_config_path.parent.mkdir(parents=True, exist_ok=True)
481 + project_config_path.write_text(
482 + json.dumps(
483 + {
484 + "chat_model": {"provider": "anthropic", "name": "project-chat"},
485 + "utility_model": {"provider": "openrouter", "name": "project-utility"},
486 + "embedding_model": {"provider": "openai", "name": "project-embedding"},
487 + }
488 + ),
489 + encoding="utf-8",
490 + )
491 +
492 + migration = MigrateModelConfig(agent=None)
493 + migration.execute()
494 +
495 + presets = yaml.safe_load((global_dir / "presets.yaml").read_text(encoding="utf-8"))
496 + assert [preset["name"] for preset in presets] == [
497 + "Default",
498 + "Existing",
499 + "Project demo",
500 + ]
501 + assert presets[0]["chat"]["name"] == "legacy-global-chat"
502 + assert presets[2]["embedding"]["name"] == "project-embedding"
503 + assert json.loads((global_dir / "config.json").read_text(encoding="utf-8")) == {
504 + "model_preset": "Default"
505 + }
506 + assert json.loads(project_config_path.read_text(encoding="utf-8")) == {
507 + "model_preset": "Project demo"
508 + }
509 + assert Path(str(project_config_path) + ".pre-unified-presets.bak").exists()
510 +
511 + before = (global_dir / "presets.yaml").read_text(encoding="utf-8")
512 + migration.execute()
513 + assert (global_dir / "presets.yaml").read_text(encoding="utf-8") == before
514 +
515 +
516 +def test_unified_preset_migration_repairs_partial_legacy_default(monkeypatch, tmp_path):
517 + _prepare_a0_tree(monkeypatch, tmp_path)
518 +
519 + from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
520 + MigrateModelConfig,
521 + )
522 +
523 + global_dir = tmp_path / "usr" / "plugins" / "_model_config"
524 + global_dir.mkdir(parents=True, exist_ok=True)
525 + (global_dir / "config.json").write_text(
526 + json.dumps(
527 + {
528 + "chat_model": {
529 + "provider": "anthropic",
530 + "name": "legacy-chat",
531 + }
532 + }
533 + ),
534 + encoding="utf-8",
535 + )
536 +
537 + MigrateModelConfig(agent=None).execute()
538 +
539 + presets = yaml.safe_load((global_dir / "presets.yaml").read_text(encoding="utf-8"))
540 + default = presets[0]
541 + assert default["name"] == "Default"
542 + assert default["chat"]["name"] == "legacy-chat"
543 + assert default["utility"]["name"] == "default-utility"
544 + assert default["embedding"]["name"] == "default-embedding"
545 +
546 +
547 +def test_unified_preset_migration_recovers_malformed_user_files(monkeypatch, tmp_path):
548 + _prepare_a0_tree(monkeypatch, tmp_path)
549 +
550 + from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
551 + MigrateModelConfig,
552 + )
553 +
554 + global_dir = tmp_path / "usr" / "plugins" / "_model_config"
555 + global_dir.mkdir(parents=True, exist_ok=True)
556 + config_path = global_dir / "config.json"
557 + presets_path = global_dir / "presets.yaml"
558 + config_path.write_text("{broken", encoding="utf-8")
559 + presets_path.write_text("not: [valid", encoding="utf-8")
560 +
561 + MigrateModelConfig(agent=None).execute()
562 +
563 + assert json.loads(config_path.read_text(encoding="utf-8")) == {
564 + "model_preset": "Default"
565 + }
566 + presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
567 + assert presets[0]["name"] == "Default"
568 + assert Path(str(config_path) + ".pre-unified-presets.bak").exists()
569 + assert Path(str(presets_path) + ".pre-unified-presets.bak").exists()
570 +
571 +
572 +def test_new_instance_bootstraps_validated_remote_presets_once(monkeypatch, tmp_path):
573 + _prepare_a0_tree(monkeypatch, tmp_path)
574 +
575 + from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
576 + MigrateModelConfig,
577 + )
578 + from plugins._model_config.extensions.python.startup_migration import (
579 + _20_bootstrap_model_presets as bootstrap,
580 + )
581 +
582 + # The normal startup order runs legacy migration before initialization.
583 + MigrateModelConfig(agent=None).execute()
584 +
585 + remote_yaml = """
586 +- name: Default
587 + chat:
588 + provider: openrouter
589 + name: openai/gpt-test
590 + api_key: must-not-persist
591 + utility:
592 + provider: openrouter
593 + name: openai/gpt-test-mini
594 + embedding:
595 + provider: huggingface
596 + name: sentence-transformers/test
597 +- name: Efficiency
598 + chat:
599 + provider: openrouter
600 + name: vendor/efficient
601 +- name: Power
602 + chat:
603 + provider: openrouter
604 + name: vendor/power
605 +""".lstrip().encode()
606 +
607 + class Response:
608 + def __enter__(self):
609 + return self
610 +
611 + def __exit__(self, *_args):
612 + return False
613 +
614 + def read(self, _limit):
615 + return remote_yaml
616 +
617 + calls = []
618 +
619 + def urlopen(request, timeout):
620 + calls.append((request.full_url, timeout, request.headers.get("User-agent")))
621 + return Response()
622 +
623 + monkeypatch.setattr(bootstrap.urllib.request, "urlopen", urlopen)
624 +
625 + result = bootstrap.BootstrapModelPresets(agent=None).execute()
626 +
627 + presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
628 + presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
629 + assert result == "remote"
630 + assert [preset["name"] for preset in presets] == ["Default", "Efficiency", "Power"]
631 + assert "api_key" not in presets[0]["chat"]
632 + assert calls == [
633 + (
634 + bootstrap.REMOTE_PRESETS_URL,
635 + bootstrap.FETCH_TIMEOUT_SECONDS,
636 + "AgentZero-Model-Preset-Bootstrap",
637 + )
638 ]
639
640 + monkeypatch.setattr(
641 + bootstrap.urllib.request,
642 + "urlopen",
643 + lambda *_args, **_kwargs: (_ for _ in ()).throw(
644 + AssertionError("bootstrap fetched more than once")
645 + ),
646 + )
647 + assert bootstrap.BootstrapModelPresets(agent=None).execute() == "existing"
648 +
649 +
650 +def test_remote_preset_failure_persists_local_fallback(monkeypatch, tmp_path):
651 + _prepare_a0_tree(monkeypatch, tmp_path)
652 +
653 + from plugins._model_config.extensions.python.startup_migration import (
654 + _20_bootstrap_model_presets as bootstrap,
655 + )
656 +
657 + monkeypatch.setattr(
658 + bootstrap.urllib.request,
659 + "urlopen",
660 + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("offline")),
661 + )
662 +
663 + result = bootstrap.BootstrapModelPresets(agent=None).execute()
664 +
665 + presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
666 + presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
667 + assert result == "fallback"
668 + assert [preset["name"] for preset in presets] == ["Default", "Balance"]
669 +
670 +
671 +def test_invalid_remote_preset_collection_uses_local_fallback(monkeypatch, tmp_path):
672 + _prepare_a0_tree(monkeypatch, tmp_path)
673 +
674 + from plugins._model_config.extensions.python.startup_migration import (
675 + _20_bootstrap_model_presets as bootstrap,
676 + )
677 +
678 + class Response:
679 + def __enter__(self):
680 + return self
681 +
682 + def __exit__(self, *_args):
683 + return False
684 +
685 + def read(self, _limit):
686 + return b"- name: Default\n chat: invalid\n"
687 +
688 + monkeypatch.setattr(
689 + bootstrap.urllib.request,
690 + "urlopen",
691 + lambda *_args, **_kwargs: Response(),
692 + )
693 +
694 + result = bootstrap.BootstrapModelPresets(agent=None).execute()
695 +
696 + presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
697 + presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
698 + assert result == "fallback"
699 + assert [preset["name"] for preset in presets] == ["Default", "Balance"]
700 +
701 +
702 +def test_remote_bootstrap_never_overwrites_existing_presets(monkeypatch, tmp_path):
703 + _prepare_a0_tree(monkeypatch, tmp_path)
704 +
705 + from plugins._model_config.extensions.python.startup_migration import (
706 + _20_bootstrap_model_presets as bootstrap,
707 + )
708 +
709 + presets_path = tmp_path / "usr" / "plugins" / "_model_config" / "presets.yaml"
710 + presets_path.parent.mkdir(parents=True, exist_ok=True)
711 + existing = "- name: Existing user preset\n"
712 + presets_path.write_text(existing, encoding="utf-8")
713 + monkeypatch.setattr(
714 + bootstrap.urllib.request,
715 + "urlopen",
716 + lambda *_args, **_kwargs: (_ for _ in ()).throw(
717 + AssertionError("existing instances must not fetch")
718 + ),
719 + )
720 +
721 + result = bootstrap.BootstrapModelPresets(agent=None).execute()
722 +
723 + assert result == "existing"
724 + assert presets_path.read_text(encoding="utf-8") == existing
725 +
726 +
727 +def test_obsolete_bootstrap_marker_does_not_block_missing_presets(monkeypatch, tmp_path):
728 + _prepare_a0_tree(monkeypatch, tmp_path)
729 +
730 + from plugins._model_config.extensions.python.startup_migration import (
731 + _20_bootstrap_model_presets as bootstrap,
732 + )
733 + from plugins._model_config.helpers import model_config
734 +
735 + marker = tmp_path / "usr" / "plugins" / "_model_config" / "preset_bootstrap.json"
736 + marker.parent.mkdir(parents=True, exist_ok=True)
737 + marker.write_text('{"source":"remote"}', encoding="utf-8")
738 + monkeypatch.setattr(
739 + bootstrap.BootstrapModelPresets,
740 + "_download_presets",
741 + lambda _self: model_config._fallback_presets(),
742 + )
743 +
744 + result = bootstrap.BootstrapModelPresets(agent=None).execute()
745 +
746 + presets_path = marker.with_name("presets.yaml")
747 + assert result == "remote"
748 + assert presets_path.exists()
749 + assert [
750 + preset["name"]
751 + for preset in yaml.safe_load(presets_path.read_text(encoding="utf-8"))
752 + ] == ["Default", "Balance"]
753 +
754 +
755 +def test_preset_rename_updates_scopes_and_unloaded_chats(monkeypatch, tmp_path):
756 + _prepare_a0_tree(monkeypatch, tmp_path)
757 +
758 + from plugins._model_config.api import model_presets
759 +
760 + global_config = tmp_path / "usr" / "plugins" / "_model_config" / "config.json"
761 + global_config.parent.mkdir(parents=True, exist_ok=True)
762 + global_config.write_text(json.dumps({"model_preset": "Research"}), encoding="utf-8")
763 +
764 + chat_path = tmp_path / "usr" / "chats" / "chat-1" / "chat.json"
765 + chat_path.parent.mkdir(parents=True, exist_ok=True)
766 + chat_path.write_text(
767 + json.dumps(
768 + {
769 + "id": "chat-1",
770 + "data": {"chat_model_override": {"preset_name": "Research"}},
771 + }
772 + ),
773 + encoding="utf-8",
774 + )
775 + monkeypatch.setattr(model_presets.AgentContext, "all", lambda *_args: [])
776 +
777 + model_presets._rename_preset_references(
778 + [{"from": "Research", "to": "Deep Research"}]
779 + )
780 +
781 + assert json.loads(global_config.read_text(encoding="utf-8")) == {
782 + "model_preset": "Deep Research"
783 + }
784 + chat = json.loads(chat_path.read_text(encoding="utf-8"))
785 + assert chat["data"]["chat_model_override"] == {
786 + "preset_name": "Deep Research"
787 + }
788 +
789
350 -def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch, tmp_path):
790 +def test_project_save_persists_only_selected_preset_reference(monkeypatch, tmp_path):
791 _prepare_a0_tree(monkeypatch, tmp_path)
792
793 from helpers import projects
@@ -355,6 +795,7 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch,
795
796 model_config.save_presets(
797 [
798 + model_config.get_presets()[0],
799 {
800 "name": "Research",
801 "chat": {"provider": "anthropic", "name": "claude-research"},
@@ -384,9 +825,7 @@ def test_project_save_copies_selected_preset_to_scoped_model_config(monkeypatch,
825 / "config.json"
826 )
827 config = json.loads(config_path.read_text(encoding="utf-8"))
387 - assert config["chat_model"]["name"] == "claude-research"
388 - assert config["utility_model"]["name"] == "utility-research"
389 - assert config["embedding_model"]["name"] == "configured-embedding"
828 + assert config == {"model_preset": "Research"}
829
830 project_json = (
831 tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "project.json"
@@ -416,35 +855,20 @@ def test_project_save_does_not_freeze_inherited_global_model_config(
855 )
856
857 project_data = projects.load_edit_project_data("demo")
419 - assert project_data["llm"]["config_scope"] == "inherited"
858 assert project_data["llm"]["has_project_config"] is False
421 - project_data["llm"].pop("config_scope")
422 - project_data["llm"].pop("has_project_config")
859
860 projects.update_project("demo", project_data)
861
862 assert not config_path.exists()
863
428 - plugins.save_plugin_config(
429 - "_model_config",
430 - "",
431 - "",
432 - {
433 - "chat_model": {"provider": "openrouter", "name": "new-global-chat"},
434 - "utility_model": {"provider": "openrouter", "name": "new-global-utility"},
435 - "embedding_model": {
436 - "provider": "huggingface",
437 - "name": "new-global-embedding",
438 - },
439 - },
440 - )
864 + plugins.save_plugin_config("_model_config", "", "", {"model_preset": "Balance"})
865
866 reloaded_data = projects.load_edit_project_data("demo")
443 - assert reloaded_data["llm"]["config_scope"] == "inherited"
444 - assert reloaded_data["llm"]["config"]["chat_model"]["name"] == "new-global-chat"
867 + assert reloaded_data["llm"]["has_project_config"] is False
868 + assert reloaded_data["llm"]["selected_preset"]["name"] == "Balance"
869
870
447 -def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path):
871 +def test_project_save_updates_existing_scoped_preset_selection(monkeypatch, tmp_path):
872 _prepare_a0_tree(monkeypatch, tmp_path)
873
874 from helpers import plugins, projects
@@ -454,16 +878,12 @@ def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path
878 "_model_config",
879 "demo",
880 "",
457 - {
458 - "chat_model": {"provider": "openrouter", "name": "project-chat"},
459 - "utility_model": {"provider": "openrouter", "name": "project-utility"},
460 - "embedding_model": {"provider": "huggingface", "name": "project-embedding"},
461 - },
881 + {"model_preset": "Default"},
882 )
883
884 project_data = projects.load_edit_project_data("demo")
465 - assert project_data["llm"]["config_scope"] == "project"
466 - project_data["llm"]["config"]["chat_model"]["name"] = "project-chat-updated"
885 + assert project_data["llm"]["has_project_config"] is True
886 + project_data["llm"]["selected_preset"]["name"] = "Balance"
887
888 projects.update_project("demo", project_data)
889
@@ -478,7 +898,7 @@ def test_project_save_updates_existing_scoped_model_config(monkeypatch, tmp_path
898 / "config.json"
899 )
900 config = json.loads(config_path.read_text(encoding="utf-8"))
481 - assert config["chat_model"]["name"] == "project-chat-updated"
901 + assert config == {"model_preset": "Balance"}
902
903
904 def test_project_extended_data_supports_multiple_plugin_sections(
@@ -513,7 +933,7 @@ def test_project_extended_data_supports_multiple_plugin_sections(
933 }
934
935 project_data = projects.load_edit_project_data("demo")
516 - assert project_data["llm"]["config_scope"] == "inherited"
936 + assert project_data["llm"]["has_project_config"] is False
937 assert project_data["extra"] == {"loaded_for": "demo", "enabled": True}
938
939 project_data["extra"] = {"enabled": True, "note": "updated"}
@@ -727,7 +1147,7 @@ def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
1147 assert utility["kwargs"] == {}
1148
1149
730 -def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_path):
1150 +def test_preset_override_preserves_default_preset_utility_context(monkeypatch, tmp_path):
1151 _prepare_a0_tree(monkeypatch, tmp_path)
1152
1153 from plugins._model_config.helpers import model_config
@@ -761,6 +1181,12 @@ def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_p
1181 "get_preset_by_name",
1182 lambda name, **kwargs: preset if name == "Fast" else None,
1183 )
1184 + default_preset = model_config.config_to_preset(base_config, "Default")
1185 + monkeypatch.setattr(
1186 + model_config,
1187 + "resolve_preset",
1188 + lambda name, **kwargs: preset if name == "Fast" else default_preset if name == "Default" else None,
1189 + )
1190
1191 utility = model_config.get_utility_model_config(FakeAgent())
1192
@@ -769,55 +1195,21 @@ def test_preset_override_preserves_configured_utility_context(monkeypatch, tmp_p
1195 assert utility["ctx_input"] == 0.4
1196
1197
772 -def test_project_save_disambiguates_same_name_project_preset(monkeypatch, tmp_path):
1198 +def test_missing_scoped_preset_falls_back_to_default(monkeypatch, tmp_path):
1199 _prepare_a0_tree(monkeypatch, tmp_path)
1200
775 - from helpers import projects
1201 + from helpers import plugins, projects
1202 from plugins._model_config.helpers import model_config
1203
778 - model_config.save_presets(
779 - [{"name": "Shared", "chat": {"provider": "global", "name": "chat"}}]
780 - )
781 - projects.create_project(
1204 + projects.create_project("demo", {"title": "Demo"})
1205 + plugins.save_plugin_config(
1206 + "_model_config",
1207 "demo",
783 - {
784 - "title": "Demo",
785 - "llm": {
786 - "selected_preset": {
787 - "scope": "project",
788 - "project_name": "demo",
789 - "name": "Shared",
790 - },
791 - "project_presets": [
792 - {"name": "Shared", "chat": {"provider": "project", "name": "chat"}}
793 - ],
794 - },
795 - },
1208 + "",
1209 + {"model_preset": "Deleted"},
1210 )
1211
798 - config = json.loads(
799 - (
800 - tmp_path
801 - / "usr"
802 - / "projects"
803 - / "demo"
804 - / ".a0proj"
805 - / "plugins"
806 - / "_model_config"
807 - / "config.json"
808 - ).read_text(encoding="utf-8")
809 - )
810 - assert config["chat_model"]["provider"] == "project"
811 -
812 - presets_yaml = (
813 - tmp_path
814 - / "usr"
815 - / "projects"
816 - / "demo"
817 - / ".a0proj"
818 - / "plugins"
819 - / "_model_config"
820 - / "presets.yaml"
821 - ).read_text(encoding="utf-8")
822 - assert "scope:" not in presets_yaml
823 - assert "project_name:" not in presets_yaml
1212 + assert model_config.get_configured_preset_name(project_name="demo") == "Default"
1213 + resolved = model_config.get_config(project_name="demo")
1214 + assert resolved["model_preset"] == "Default"
1215 + assert resolved["chat_model"]["name"] == "default-chat"
tests/test_model_config_ui.py
+47 -10
@@ -11,29 +11,31 @@ def read(*parts: str) -> str:
11 def test_model_config_text_buttons_have_shared_primitive() -> None:
12 buttons_css = read("webui", "css", "buttons.css")
13 preset_modal = read("plugins", "_model_config", "webui", "main.html")
14 - config_modal = read("plugins", "_model_config", "webui", "config.html")
14 + overview = read("plugins", "_model_config", "webui", "preset-overview.html")
15
16 assert ".text-button {" in buttons_css
17 assert "appearance: none;" in buttons_css
18 assert ".text-button:hover:not(:disabled)" in buttons_css
19 assert ".text-button .material-symbols-outlined" in buttons_css
20 - assert 'class="text-button preset-add-btn"' in preset_modal
21 - assert 'class="text-button preset-delete-btn"' in preset_modal
22 - assert 'class="text-button"' in config_modal
20 + assert 'class="text-button"' in preset_modal
21 + assert 'class="model-preset-actions"' in overview
22
23
24 def test_model_preset_rows_keep_stable_identity_after_middle_delete() -> None:
25 preset_modal = read("plugins", "_model_config", "webui", "main.html")
26 preset_store = read("plugins", "_model_config", "webui", "model-config-store.js")
27
29 - assert 'x-data="$store.modelConfig.createPresetEditor()"' in preset_modal
28 + assert 'x-data="$store.modelConfig.createPresetEditor($store.modelConfig.presetEditorInitialName)"' in preset_modal
29 assert ':key="preset._key"' in preset_modal
31 - assert "createPresetEditor()" in preset_store
30 + assert 'x-model.number="selectedKey"' in preset_modal
31 + assert "createPresetEditor(initialName = '')" in preset_store
32 + assert "preparePresetEditor($store.chats?.selected || '')" in preset_modal
33 assert "_key: nextPresetKey++" in preset_store
33 - assert "removePreset(index)" in preset_store
34 + assert "removeSelectedPreset()" in preset_store
35 + assert "selectedKey" in preset_store
36 assert ':key="idx"' not in preset_modal
37 assert "JSON.parse(JSON.stringify" not in preset_modal
36 - assert "presets.splice" not in preset_modal
38 + assert "presets.splice" not in preset_store
39
40
41 def test_model_preset_editor_can_reset_to_bundled_defaults() -> None:
@@ -41,11 +43,46 @@ def test_model_preset_editor_can_reset_to_bundled_defaults() -> None:
43 preset_store = read("plugins", "_model_config", "webui", "model-config-store.js")
44
45 assert '$confirmClick($event, () => resetPresets())' in preset_modal
44 - assert "Reset to default" in preset_modal
46 + assert "Restore bundled presets" in preset_modal
47 assert "async resetPresets()" in preset_store
48 assert "if (!await store.resetGlobalPresets()) return;" in preset_store
49 assert "this.refreshPresets();" in preset_store
48 - assert "refreshPresets();" in preset_modal
50 +
51 +
52 +def test_default_preset_is_locked_and_shared_overview_is_reused() -> None:
53 + preset_modal = read("plugins", "_model_config", "webui", "main.html")
54 + config_modal = read("plugins", "_model_config", "webui", "config.html")
55 + settings_summary = read("plugins", "_model_config", "webui", "models-summary.html")
56 + helper = read("plugins", "_model_config", "helpers", "model_config.py")
57 +
58 + assert '<template x-if="canRenameSelected">' in preset_modal
59 + assert "get canRenameSelected()" in read(
60 + "plugins", "_model_config", "webui", "model-config-store.js"
61 + )
62 + assert "Default cannot be renamed or deleted." not in preset_modal
63 + assert ":readonly=" not in preset_modal
64 + assert "The Default preset cannot be deleted or renamed." in helper
65 + assert "preserveImplicitDefaults" in read(
66 + "plugins", "_model_config", "webui", "model-config-store.js"
67 + )
68 + component_path = "/plugins/_model_config/webui/preset-overview.html"
69 + assert component_path in config_modal
70 + assert component_path in settings_summary
71 + assert "Per-project / agent" in read(
72 + "plugins", "_model_config", "webui", "preset-overview.html"
73 + )
74 +
75 +
76 +def test_preset_editor_uses_standard_modal_footer_buttons() -> None:
77 + preset_modal = read("plugins", "_model_config", "webui", "main.html")
78 + api_keys_modal = read("plugins", "_model_config", "webui", "api-keys.html")
79 +
80 + for content in (preset_modal, api_keys_modal):
81 + assert '<div class="modal-footer" data-modal-footer>' in content
82 + assert 'class="btn btn-ok"' in content
83 + assert 'class="btn btn-cancel"' in content
84 +
85 + assert "preset-editor-footer" not in preset_modal
86
87
88 def test_plugin_settings_reset_is_explicit_and_does_not_capture_toast_early() -> None:
tests/test_welcome_composer_static.py
+1 -1
@@ -121,7 +121,7 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
121 assert 'document.addEventListener("model-configured"' in gate_store
122 assert 'document.addEventListener("model-setup-changed"' in gate_store
123 assert "bypassModelGate: true" in gate_store
124 - assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
124 + assert "modelConfigStore.openPresetEditor(" in gate_store
125 assert 'openPluginConfig("_oauth"' not in gate_store
126 assert "Your message sends automatically once a model is connected." in gate_component
127 assert "openOnboarding('cloud')" in gate_component
webui/components/chat/AGENTS.md
+1 -1
@@ -22,7 +22,7 @@
22 - Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
23 - Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
24 - While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
25 -- The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and plugin settings modals; do not duplicate provider/model/key forms inline.
25 +- The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and model-preset editor modals; do not duplicate provider/model/key forms inline.
26 - A connected OAuth account without Main/Utility model selection is its own gate state; route to model configuration and do not select models automatically.
27 - Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
28 - The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
webui/components/chat/model-gate-store.js
+9 -8
@@ -2,7 +2,6 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4 import { store as onboardingStore } from "/plugins/_onboarding/webui/onboarding-store.js";
5 -import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
5 import { toastFrontendError } from "/components/notifications/notification-store.js";
6
7 const ONBOARDING_MODAL_PATH = "/plugins/_onboarding/webui/onboarding.html";
@@ -135,16 +134,18 @@ export const store = createStore("modelGate", {
134 },
135
136 async openAdvancedModelConfiguration() {
138 - await this.openPluginConfig("_model_config", "Advanced model configuration");
139 - },
140 -
141 - async openPluginConfig(pluginName, title) {
137 try {
143 - await pluginSettingsStore.openConfig(pluginName);
138 + const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
139 + await modelConfigStore.openPresetEditor(
140 + data?.selected_preset || data?.configured_preset || "Default"
141 + );
142 await this.dispatchPendingIfConfigured();
143 } catch (error) {
146 - console.error(`Could not open ${pluginName} configuration:`, error);
147 - void toastFrontendError(error?.message || "Could not open configuration.", title);
144 + console.error("Could not open model preset editor:", error);
145 + void toastFrontendError(
146 + error?.message || "Could not open model presets.",
147 + "Advanced model configuration"
148 + );
149 }
150 },
151
webui/components/projects/AGENTS.md
+1
@@ -16,6 +16,7 @@
16 - Keep project API payloads synchronized with backend project handlers.
17 - Do not expose project secrets in logs, URLs, or long-lived frontend state unnecessarily.
18 - Preserve scoped settings interactions with plugins, models, skills, and MCP servers.
19 +- Project model settings select a global `_model_config` preset; they do not own copied model dictionaries or project-local preset definitions.
20
21 ## Work Guidance
22
webui/components/projects/project-edit-llm.html
+1 -2
@@ -17,13 +17,12 @@
17 <select class="projects-form-select"
18 x-model="$store.projects.selectedProject.llm.preset_key"
19 @change="$store.projects.applySelectedLlmPreset()">
20 - <option value="current">Current project config</option>
20 <template x-for="preset in $store.projects.selectedProject.llm.global_presets" :key="$store.projects.getLlmPresetKey(preset)">
21 <option :value="$store.projects.getLlmPresetKey(preset)" x-text="preset.name"></option>
22 </template>
23 </select>
24 <button type="button" class="button icon-button project-llm-edit-presets"
26 - @click="openModal('/plugins/_model_config/webui/main.html')">
25 + @click="$store.projects.editSelectedProjectPresets()">
26 <span class="icon material-symbols-outlined">tune</span>
27 <span>Edit Presets</span>
28 </button>
webui/components/projects/projects-store.js
+27 -121
@@ -5,7 +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, configFromPreset } from "/plugins/_model_config/webui/model-config-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
@@ -373,6 +373,8 @@ const model = {
373 notifications.NotificationPriority.NORMAL,
374 true
375 );
376 + const contextId = chatsStore.getSelectedChatId();
377 + if (contextId) await modelConfigStore.refreshSwitcher(contextId);
378 return response.data;
379 } else {
380 notifications.toastFrontendError(
@@ -441,56 +443,37 @@ const model = {
443 const presetsResult = await api.callJsonApi("/plugins/_model_config/model_presets", {
444 action: "get",
445 project_name: projectName || "",
444 - scope: projectName ? "combined" : "global",
446 });
447 return this._normalizeProjectLlmData({
447 - config: configResult.config || {},
448 selected_preset: {
449 - scope: "current",
450 - project_name: projectName || "",
451 - name: "Current config",
449 + scope: "global",
450 + project_name: "",
451 + name: configResult.selected_preset || configResult.configured_preset || "Default",
452 },
453 global_presets: presetsResult.global_presets || presetsResult.presets || [],
454 - project_presets: presetsResult.project_presets || [],
454 presets: presetsResult.presets || [],
455 }, projectName);
456 },
457
458 _normalizeProjectLlmData(raw, projectName) {
459 const data = raw || {};
461 - const config = JSON.parse(JSON.stringify(data.config || {}));
462 - config.chat_model = config.chat_model || {};
463 - config.utility_model = config.utility_model || {};
464 - config.embedding_model = config.embedding_model || {};
465 - modelConfigStore.initConfigFields(config);
466 -
460 const globalPresets = this._normalizePresetsWithScope(
461 data.global_presets || [],
462 "global",
463 ""
464 );
472 - const projectPresets = this._normalizePresetsWithScope(
473 - data.project_presets || [],
474 - "project",
475 - projectName || ""
476 - );
477 - const presets = [
478 - ...globalPresets,
479 - ...projectPresets,
480 - ];
465 + const presets = [...globalPresets];
466 const selected = data.selected_preset || {
482 - scope: "current",
483 - project_name: projectName || "",
484 - name: "Current config",
467 + scope: "global",
468 + project_name: "",
469 + name: "Default",
470 };
471 return {
487 - config,
472 selected_preset: selected,
473 preset_key: this.getLlmPresetKey(selected),
474 global_presets: globalPresets,
491 - project_presets: projectPresets,
475 + project_presets: [],
476 presets,
493 - new_preset_name: "",
477 };
478 },
479
@@ -506,27 +489,19 @@ const model = {
489 },
490
491 getLlmPresetKey(preset) {
509 - if (!preset || preset.scope === "current") return "current";
492 + if (!preset) return "global||Default";
493 return `${preset.scope || "global"}|${preset.project_name || ""}|${preset.name || ""}`;
494 },
495
496 _findLlmPresetByKey(key) {
497 const llm = this.selectedProject?.llm;
515 - if (!llm || key === "current") return null;
498 + if (!llm) return null;
499 return (llm.presets || []).find((preset) => this.getLlmPresetKey(preset) === key) || null;
500 },
501
502 applySelectedLlmPreset() {
503 const llm = this.selectedProject?.llm;
504 if (!llm) return;
522 - if (llm.preset_key === "current") {
523 - llm.selected_preset = {
524 - scope: "current",
525 - project_name: this.selectedProject?.name || "",
526 - name: "Current config",
527 - };
528 - return;
529 - }
505 const preset = this._findLlmPresetByKey(llm.preset_key);
506 if (!preset) return;
507 llm.selected_preset = {
@@ -534,96 +509,27 @@ const model = {
509 project_name: preset.project_name || "",
510 name: preset.name || "",
511 };
537 - llm.config = this._configFromPreset(preset, llm.config || {});
538 - modelConfigStore.initConfigFields(llm.config);
512 },
513
541 - markLlmCurrent() {
514 + async editSelectedProjectPresets() {
515 const llm = this.selectedProject?.llm;
516 if (!llm) return;
517 + const selectedName = llm.selected_preset?.name || "Default";
518 + await modelConfigStore.openPresetEditor(selectedName);
519 + await modelConfigStore.loadGlobalPresets();
520 + llm.global_presets = this._normalizePresetsWithScope(
521 + modelConfigStore.globalPresets,
522 + "global",
523 + ""
524 + );
525 + llm.presets = [...llm.global_presets];
526 + const remappedName = modelConfigStore.remapPresetName(selectedName);
527 llm.selected_preset = {
545 - scope: "current",
546 - project_name: this.selectedProject?.name || "",
547 - name: "Current config",
548 - };
549 - llm.preset_key = "current";
550 - },
551 -
552 - _configFromPreset(preset, baseConfig) {
553 - return configFromPreset(preset, baseConfig || {}, true);
554 - },
555 -
556 - _cleanModelSlot(slot, stripApiKey = true) {
557 - const clean = JSON.parse(JSON.stringify(slot || {}));
558 - for (const key of Object.keys(clean)) {
559 - if (key.startsWith("_")) delete clean[key];
560 - }
561 - if (stripApiKey) delete clean.api_key;
562 - return clean;
563 - },
564 -
565 - _presetFromLlmConfig(name, config) {
566 - return {
567 - name,
568 - chat: this._cleanModelSlot(config?.chat_model || {}, true),
569 - utility: this._cleanModelSlot(config?.utility_model || {}, true),
570 - embedding: this._cleanModelSlot(config?.embedding_model || {}, true),
571 - };
572 - },
573 -
574 - async saveSelectedLlmProjectPreset() {
575 - const project = this.selectedProject;
576 - const llm = project?.llm;
577 - const name = (llm?.new_preset_name || "").trim();
578 - if (!project || !llm || !name) return;
579 -
580 - const preset = {
581 - ...this._presetFromLlmConfig(name, llm.config || {}),
582 - scope: "project",
583 - project_name: project.name || "",
584 - };
585 - const projectPresets = llm.project_presets || (llm.project_presets = []);
586 - const existingIndex = projectPresets.findIndex((p) => p.name === name);
587 - if (existingIndex >= 0) projectPresets.splice(existingIndex, 1, preset);
588 - else projectPresets.push(preset);
589 -
590 - llm.presets = [
591 - ...(llm.global_presets || []),
592 - ...(llm.project_presets || []),
593 - ];
594 - llm.selected_preset = {
595 - scope: "project",
596 - project_name: project.name || "",
597 - name,
528 + scope: "global",
529 + project_name: "",
530 + name: remappedName,
531 };
532 llm.preset_key = this.getLlmPresetKey(llm.selected_preset);
600 - llm.new_preset_name = "";
601 -
602 - if (!project._meta?.creating && project.name) {
603 - await api.callJsonApi("/plugins/_model_config/model_presets", {
604 - action: "save",
605 - scope: "project",
606 - project_name: project.name,
607 - presets: llm.project_presets,
608 - });
609 - notifications.toastFrontendSuccess(
610 - "Project preset saved",
611 - "LLM preset",
612 - 3,
613 - "projects_llm",
614 - notifications.NotificationPriority.NORMAL,
615 - true
616 - );
617 - } else {
618 - notifications.toastFrontendSuccess(
619 - "Project preset ready",
620 - "LLM preset",
621 - 3,
622 - "projects_llm",
623 - notifications.NotificationPriority.NORMAL,
624 - true
625 - );
626 - }
533 },
534
535 async browseSelected(...relPath) {