Reflect connector model overrides in Web UI
Render custom per-chat model overrides in the model switcher instead of hiding them behind a generic Custom label. Mark model override updates dirty so an already-open Web UI refreshes after CLI or Web UI changes, without exposing API key values in labels. Add focused regression coverage for switcher rendering hooks and state-sync notifications.
Alessandro committed
May 12, 2026 at 16:04 UTC
7b61ceb241ed6f171e58bde4013beb4b1344a679
5 files changed
+173
-4
plugins/_a0_connector/api/v1/model_switcher.py
+18
@@ -1,11 +1,14 @@
1
"""POST /api/plugins/_a0_connector/v1/model_switcher."""
2
from __future__ import annotations
3
4
+import time
5
from typing import Callable
6
7
from helpers.api import Request, Response
8
import plugins._a0_connector.api.v1.base as connector_base
9
10
+_MODEL_OVERRIDE_REVISION_KEY = "_model_config_override_revision"
11
+
12
13
def _model_payload(config: dict | None, *, has_api_key: bool = False) -> dict[str, object]:
14
config = config or {}
@@ -76,6 +79,18 @@ def _provider_payload(
79
return options
80
81
82
+def _notify_model_override_changed(context: object, context_id: str) -> None:
83
+ if hasattr(context, "set_output_data"):
84
+ context.set_output_data(_MODEL_OVERRIDE_REVISION_KEY, time.time())
85
+
86
+ try:
87
+ from helpers.state_monitor_integration import mark_dirty_for_context
88
+
89
+ mark_dirty_for_context(context_id, reason="a0_connector.model_switcher")
90
+ except Exception:
91
+ pass
92
+
93
+
94
class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
95
async def process(self, input: dict, request: Request) -> dict | Response:
96
from agent import AgentContext
@@ -142,11 +157,13 @@ class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
157
return Response(status=404, response=f"Preset '{preset_name}' not found")
158
context.set_data("chat_model_override", {"preset_name": preset_name})
159
save_tmp_chat(context)
160
+ _notify_model_override_changed(context, context_id)
161
return build_state()
162
163
if action == "clear":
164
context.set_data("chat_model_override", None)
165
save_tmp_chat(context)
166
+ _notify_model_override_changed(context, context_id)
167
return build_state()
168
169
if action == "set_override":
@@ -162,6 +179,7 @@ class ModelSwitcher(connector_base.ProtectedConnectorApiHandler):
179
override["utility"] = utility_model
180
context.set_data("chat_model_override", override)
181
save_tmp_chat(context)
182
+ _notify_model_override_changed(context, context_id)
183
return build_state()
184
185
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/api/model_override.py
+18
@@ -1,8 +1,23 @@
1
+import time
2
+
3
from helpers.api import ApiHandler, Request, Response
4
from helpers.persist_chat import save_tmp_chat
5
from agent import AgentContext
6
from plugins._model_config.helpers import model_config
7
8
+_MODEL_OVERRIDE_REVISION_KEY = "_model_config_override_revision"
9
+
10
+
11
+def _notify_model_override_changed(ctx: AgentContext) -> None:
12
+ ctx.set_output_data(_MODEL_OVERRIDE_REVISION_KEY, time.time())
13
+
14
+ try:
15
+ from helpers.state_monitor_integration import mark_dirty_for_context
16
+
17
+ mark_dirty_for_context(ctx.id, reason="model_config.model_override")
18
+ except Exception:
19
+ pass
20
+
21
22
class ModelOverride(ApiHandler):
23
async def process(self, input: dict, request: Request) -> dict | Response:
@@ -29,6 +44,7 @@ class ModelOverride(ApiHandler):
44
return Response(status=400, response="Missing or invalid override config")
45
ctx.set_data("chat_model_override", override_config)
46
save_tmp_chat(ctx)
47
+ _notify_model_override_changed(ctx)
48
return {"ok": True, "override": override_config}
49
50
elif action == "set_preset":
@@ -45,11 +61,13 @@ class ModelOverride(ApiHandler):
61
override_value = {"preset_name": preset_name}
62
ctx.set_data("chat_model_override", override_value)
63
save_tmp_chat(ctx)
64
+ _notify_model_override_changed(ctx)
65
return {"ok": True, "preset_name": preset_name}
66
67
elif action == "clear":
68
ctx.set_data("chat_model_override", None)
69
save_tmp_chat(ctx)
70
+ _notify_model_override_changed(ctx)
71
return {"ok": True, "override": None}
72
73
return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/extensions/webui/apply_snapshot_before/refresh-switcher.js
new
+27
@@ -0,0 +1,27 @@
1
+import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
2
+
3
+const OVERRIDE_REVISION_KEY = "_model_config_override_revision";
4
+
5
+let lastContextId = "";
6
+let lastRevision = null;
7
+
8
+export default async function refreshSwitcherOnOverrideRevision(ctx) {
9
+ const snapshot = ctx?.snapshot;
10
+ const contextId = String(snapshot?.context || "");
11
+
12
+ if (!contextId) {
13
+ lastContextId = "";
14
+ lastRevision = null;
15
+ return;
16
+ }
17
+
18
+ const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : [];
19
+ const activeContext = contexts.find(item => item?.id === contextId) || null;
20
+ const revision = activeContext?.[OVERRIDE_REVISION_KEY] || null;
21
+
22
+ if (contextId === lastContextId && revision === lastRevision) return;
23
+
24
+ lastContextId = contextId;
25
+ lastRevision = revision;
26
+ await modelConfigStore.refreshSwitcher(contextId);
27
+}
plugins/_model_config/webui/switcher-mixin.js
+32
-4
@@ -5,6 +5,20 @@ const CREATE_AGENT_PROFILE_PROMPT = `I want to create a new Agent Zero agent pro
5
6
Use the a0-create-agent skill. Guide me gently with one or two questions per turn. Start by asking what this agent should be excellent at, infer sensible defaults, and only produce the AgentProfileBlueprint JSON after we confirm the compact profile summary. Prefer a normal user profile in /a0/usr/agents unless I choose another scope.`;
7
8
+function normalizeModelIdentity(value) {
9
+ if (!value || typeof value !== "object") return null;
10
+ const provider = String(value.provider || "").trim();
11
+ const name = String(value.name || "").trim();
12
+ if (!provider && !name) return null;
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 || "";
20
+}
21
+
22
export const switcherState = {
23
switcherAllowed: false,
24
switcherOverride: null,
@@ -216,7 +230,10 @@ export const switcherMethods = {
230
getSwitcherLabel() {
231
const o = this.switcherOverride;
232
if (!o) return 'Default LLM';
219
- return o.preset_name || o.name || o.provider || 'Custom';
233
+ if (o.preset_name) return o.preset_name;
234
+
235
+ const models = this.getCustomOverrideModels();
236
+ return formatModelIdentity(models.main) || formatModelIdentity(models.utility) || o.name || o.provider || 'Custom';
237
},
238
239
getActivePreset() {
@@ -227,10 +244,21 @@ export const switcherMethods = {
244
245
getActiveModels() {
246
const preset = this.getActivePreset();
230
- if (!preset) return { main: null, utility: null };
247
+ if (preset) {
248
+ return {
249
+ main: normalizeModelIdentity(preset.chat),
250
+ utility: normalizeModelIdentity(preset.utility),
251
+ };
252
+ }
253
+ return this.getCustomOverrideModels();
254
+ },
255
+
256
+ getCustomOverrideModels() {
257
+ const o = this.switcherOverride;
258
+ if (!o || o.preset_name) return { main: null, utility: null };
259
return {
232
- main: preset.chat?.name ? { provider: preset.chat.provider, name: preset.chat.name } : null,
233
- utility: preset.utility?.name ? { provider: preset.utility.provider, name: preset.utility.name } : null,
260
+ main: normalizeModelIdentity(o.chat || o),
261
+ utility: normalizeModelIdentity(o.utility),
262
};
263
},
264
};
tests/test_model_config_api_keys.py
+78
@@ -118,6 +118,84 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
118
assert "$store.modelConfig.resetApiKeyDrafts();" in modal_content
119
120
121
+def test_model_switcher_frontend_renders_custom_overrides():
122
+ switcher_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
123
+ refresh_extension_path = (
124
+ PROJECT_ROOT
125
+ / "plugins"
126
+ / "_model_config"
127
+ / "extensions"
128
+ / "webui"
129
+ / "apply_snapshot_before"
130
+ / "refresh-switcher.js"
131
+ )
132
+
133
+ switcher_content = switcher_path.read_text(encoding="utf-8")
134
+ refresh_extension_content = refresh_extension_path.read_text(encoding="utf-8")
135
+
136
+ assert "function normalizeModelIdentity(value)" in switcher_content
137
+ assert "formatModelIdentity(models.main)" in switcher_content
138
+ assert "formatModelIdentity(models.utility)" in switcher_content
139
+ assert "normalizeModelIdentity(o.chat || o)" in switcher_content
140
+ assert "normalizeModelIdentity(o.utility)" in switcher_content
141
+ assert "_model_config_override_revision" in refresh_extension_content
142
+ assert "modelConfigStore.refreshSwitcher(contextId)" in refresh_extension_content
143
+
144
+
145
+def test_model_override_notifies_state_sync(monkeypatch):
146
+ from helpers import state_monitor_integration
147
+ from plugins._model_config.api import model_override
148
+
149
+ calls = []
150
+
151
+ class FakeContext:
152
+ id = "ctx-1"
153
+
154
+ def __init__(self):
155
+ self.output_data = {}
156
+
157
+ def set_output_data(self, key, value):
158
+ self.output_data[key] = value
159
+
160
+ ctx = FakeContext()
161
+ monkeypatch.setattr(
162
+ state_monitor_integration,
163
+ "mark_dirty_for_context",
164
+ lambda context_id, *, reason=None: calls.append((context_id, reason)),
165
+ )
166
+
167
+ model_override._notify_model_override_changed(ctx)
168
+
169
+ assert "_model_config_override_revision" in ctx.output_data
170
+ assert calls == [("ctx-1", "model_config.model_override")]
171
+
172
+
173
+def test_connector_model_switcher_notifies_state_sync(monkeypatch):
174
+ from helpers import state_monitor_integration
175
+ from plugins._a0_connector.api.v1 import model_switcher
176
+
177
+ calls = []
178
+
179
+ class FakeContext:
180
+ def __init__(self):
181
+ self.output_data = {}
182
+
183
+ def set_output_data(self, key, value):
184
+ self.output_data[key] = value
185
+
186
+ ctx = FakeContext()
187
+ monkeypatch.setattr(
188
+ state_monitor_integration,
189
+ "mark_dirty_for_context",
190
+ lambda context_id, *, reason=None: calls.append((context_id, reason)),
191
+ )
192
+
193
+ model_switcher._notify_model_override_changed(ctx, "ctx-1")
194
+
195
+ assert "_model_config_override_revision" in ctx.output_data
196
+ assert calls == [("ctx-1", "a0_connector.model_switcher")]
197
+
198
+
199
def test_model_config_provider_switch_resets_custom_api_base():
200
model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
201
content = model_field_path.read_text(encoding="utf-8")