feat: add model config UI with presets, model search, and API key management
keyboardstaff committed
Mar 14, 2026 at 23:20 UTC
fc3a72e3a44dfdcbcfc300ba710694901945f329
4 files changed
+934
plugins/_model_config/api/model_presets.py
new
+38
@@ -0,0 +1,38 @@
1
+from helpers.api import ApiHandler, Request, Response
2
+from helpers import plugins
3
+from plugins._model_config.helpers import model_config
4
+
5
+
6
+class ModelPresets(ApiHandler):
7
+ async def process(self, input: dict, request: Request) -> dict | Response:
8
+ action = input.get("action", "get")
9
+ project_name = input.get("project_name", "")
10
+ agent_profile = input.get("agent_profile", "")
11
+
12
+ if action == "get":
13
+ presets = model_config.get_presets(
14
+ project_name=project_name or None,
15
+ agent_profile=agent_profile or None,
16
+ )
17
+ return {"ok": True, "presets": presets}
18
+
19
+ elif action == "save":
20
+ presets = input.get("presets")
21
+ if not isinstance(presets, list):
22
+ return Response(status=400, response="presets must be an array")
23
+
24
+ # Load current config, update presets, save
25
+ cfg = model_config.get_config(
26
+ project_name=project_name or None,
27
+ agent_profile=agent_profile or None,
28
+ )
29
+ if not cfg:
30
+ cfg = plugins.get_default_plugin_config("_model_config") or {}
31
+
32
+ cfg["model_presets"] = presets
33
+ plugins.save_plugin_config(
34
+ "_model_config", project_name, agent_profile, cfg
35
+ )
36
+ return {"ok": True, "presets": presets}
37
+
38
+ return Response(status=400, response=f"Unknown action: {action}")
plugins/_model_config/webui/api-keys.html
new
+143
@@ -0,0 +1,143 @@
1
+<html>
2
+<head>
3
+ <title>Manage API Keys</title>
4
+</head>
5
+
6
+<body>
7
+<div x-data="{
8
+ _fns: null,
9
+ providers: [],
10
+ keys: {},
11
+ loading: true,
12
+ _debounceTimers: {},
13
+
14
+ async init() {
15
+ this._fns = await import('/plugins/_model_config/webui/config-store.js');
16
+ this.loading = true;
17
+ try {
18
+ const data = await this._fns.loadConfigData();
19
+ const chatProviders = data.chat_providers || [];
20
+ const embedProviders = data.embedding_providers || [];
21
+ const apiKeyStatus = data.api_key_status || {};
22
+
23
+ // Deduplicate providers
24
+ const seen = new Set();
25
+ this.providers = [];
26
+ for (const p of [...chatProviders, ...embedProviders]) {
27
+ if (!p.value || seen.has(p.value.toLowerCase())) continue;
28
+ seen.add(p.value.toLowerCase());
29
+ this.providers.push({
30
+ value: p.value,
31
+ label: p.label || p.value,
32
+ has_key: !!apiKeyStatus[p.value]
33
+ });
34
+ }
35
+ this.providers.sort((a, b) => a.label.localeCompare(b.label));
36
+
37
+ this.keys = {};
38
+ for (const p of this.providers) {
39
+ this.keys[p.value] = '';
40
+ }
41
+ } catch (e) {
42
+ console.error('Failed to load API key providers:', e);
43
+ } finally {
44
+ this.loading = false;
45
+ }
46
+ },
47
+
48
+ onKeyInput(provider) {
49
+ clearTimeout(this._debounceTimers[provider]);
50
+ this._debounceTimers[provider] = setTimeout(() => this.saveKey(provider), 800);
51
+ },
52
+
53
+ async saveKey(provider) {
54
+ const val = this.keys[provider];
55
+ if (!val) return;
56
+ try {
57
+ await this._fns.saveApiKey(provider, val);
58
+ const p = this.providers.find(x => x.value === provider);
59
+ if (p) p.has_key = true;
60
+ } catch (e) {
61
+ console.error('Failed to save API key:', e);
62
+ }
63
+ },
64
+
65
+ async revealKey(provider) {
66
+ try {
67
+ const val = await this._fns.revealApiKey(provider);
68
+ if (val) this.keys[provider] = val;
69
+ } catch (e) {
70
+ console.error('Failed to reveal API key:', e);
71
+ }
72
+ }
73
+}">
74
+
75
+ <div class="api-keys-section">
76
+ <div class="section-title">API Keys</div>
77
+ <div class="section-description">
78
+ API keys for model providers and services used by Agent Zero. You can set multiple API keys separated by a comma (,). They will be used in round-robin fashion.<br>
79
+ For more information about Agent Zero Venice provider, see <a href="http://agent-zero.ai/?community/api-dashboard/about" target="_blank">Agent Zero Venice</a>.
80
+ </div>
81
+
82
+ <div x-show="loading" style="text-align:center; padding: 20px;">
83
+ <span class="material-symbols-outlined spinning">progress_activity</span>
84
+ </div>
85
+
86
+ <div x-show="!loading">
87
+ <template x-for="provider in providers" :key="provider.value">
88
+ <div class="field">
89
+ <div class="field-label">
90
+ <div class="field-title" x-text="provider.label"></div>
91
+ </div>
92
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
93
+ <input :type="showKey ? 'text' : 'password'"
94
+ x-model="keys[provider.value]"
95
+ :placeholder="provider.has_key ? '••••••••••••' : ''"
96
+ autocomplete="off"
97
+ @input="onKeyInput(provider.value)"
98
+ style="padding-right:32px;" />
99
+ <span class="material-symbols-outlined eye-toggle"
100
+ @click="
101
+ showKey = !showKey;
102
+ if (showKey && !keys[provider.value] && provider.has_key) {
103
+ revealKey(provider.value);
104
+ }
105
+ "
106
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
107
+ </div>
108
+ </div>
109
+ </template>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="modal-footer" data-modal-footer>
114
+ <button class="btn btn-cancel" @click="closeModal()">Close</button>
115
+ </div>
116
+</div>
117
+
118
+<style>
119
+ .api-keys-section {
120
+ border: 1px solid var(--color-border);
121
+ border-radius: 8px;
122
+ padding: 16px;
123
+ }
124
+ .api-keys-section .section-title {
125
+ margin-top: 0;
126
+ }
127
+ .eye-toggle {
128
+ position: absolute;
129
+ right: 8px;
130
+ top: 50%;
131
+ transform: translateY(-50%);
132
+ font-size: 18px;
133
+ cursor: pointer;
134
+ user-select: none;
135
+ opacity: 0.6;
136
+ z-index: 1;
137
+ }
138
+ .eye-toggle:hover {
139
+ opacity: 1;
140
+ }
141
+</style>
142
+</body>
143
+</html>
plugins/_model_config/webui/config-store.js
new
+115
@@ -0,0 +1,115 @@
1
+export const MODEL_SECTIONS = [
2
+ { key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
3
+ { key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
4
+ { key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
5
+];
6
+
7
+export function kwargsToText(obj) {
8
+ if (!obj || typeof obj !== 'object') return '';
9
+ return Object.entries(obj).map(([k, v]) => {
10
+ if (typeof v === 'string') return k + '=' + JSON.stringify(v);
11
+ return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : String(v));
12
+ }).join('\n');
13
+}
14
+
15
+export function textToKwargs(text) {
16
+ const d = {};
17
+ (text || '').split('\n').forEach(l => {
18
+ l = l.trim();
19
+ if (!l || l.startsWith('#')) return;
20
+ const i = l.indexOf('=');
21
+ if (i > 0) {
22
+ const key = l.substring(0, i).trim();
23
+ let val = l.substring(i + 1).trim();
24
+ try { val = JSON.parse(val); } catch {}
25
+ d[key] = val;
26
+ }
27
+ });
28
+ return d;
29
+}
30
+
31
+export function textToHeaders(text) {
32
+ const d = {};
33
+ (text || '').split('\n').forEach(l => {
34
+ l = l.trim();
35
+ if (!l || l.startsWith('#')) return;
36
+ const i = l.indexOf('=');
37
+ if (i > 0) d[l.substring(0, i).trim()] = l.substring(i + 1).trim();
38
+ });
39
+ return d;
40
+}
41
+
42
+export async function loadConfigData() {
43
+ const res = await fetchApi('/plugins/_model_config/model_config_get', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({})
47
+ });
48
+ return await res.json();
49
+}
50
+
51
+export async function saveApiKey(provider, value) {
52
+ await fetchApi('/plugins/_model_config/api_keys', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({ action: 'set', keys: { [provider]: value } })
56
+ });
57
+}
58
+
59
+export async function revealApiKey(provider) {
60
+ const res = await fetchApi('/plugins/_model_config/api_keys', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ action: 'reveal', provider })
64
+ });
65
+ const data = await res.json();
66
+ return data.value || '';
67
+}
68
+
69
+export async function searchModels(provider, query, modelType, apiBase) {
70
+ if (!provider) return [];
71
+ try {
72
+ const res = await fetchApi('/plugins/_model_config/model_search', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ provider, query: query || '', model_type: modelType || 'chat', api_base: apiBase || '' })
76
+ });
77
+ const data = await res.json();
78
+ return data.models || [];
79
+ } catch (e) {
80
+ console.error('Model search failed:', e);
81
+ return [];
82
+ }
83
+}
84
+
85
+export function initConfigFields(config) {
86
+ if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
87
+ if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
88
+ if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
89
+ if (config) config._browser_headers_text = Object.entries(config.browser_http_headers || {}).map(([k, v]) => k + '=' + v).join('\n');
90
+ if (config) {
91
+ if (!config.model_presets) config.model_presets = [];
92
+ config.model_presets = config.model_presets.map(p => ({
93
+ name: p.name || '',
94
+ chat: { provider: '', name: '', api_key: '', api_base: '', ...(p.chat || {}) },
95
+ utility: { provider: '', name: '', api_key: '', api_base: '', ...(p.utility || {}) },
96
+ }));
97
+ }
98
+}
99
+
100
+export async function initConfigComponent(comp, config) {
101
+ const data = await loadConfigData();
102
+ comp.chatProviders = data.chat_providers || [];
103
+ comp.embeddingProviders = data.embedding_providers || [];
104
+ comp.apiKeyStatus = data.api_key_status || {};
105
+ const allProviders = [...comp.chatProviders, ...comp.embeddingProviders];
106
+ const newKeys = { ...comp.apiKeyValues };
107
+ const seen = new Set();
108
+ for (const p of allProviders) {
109
+ if (!p.value || seen.has(p.value)) continue;
110
+ seen.add(p.value);
111
+ if (!(p.value in newKeys)) newKeys[p.value] = '';
112
+ }
113
+ comp.apiKeyValues = newKeys;
114
+ initConfigFields(config);
115
+}
plugins/_model_config/webui/config.html
new
+638
@@ -0,0 +1,638 @@
1
+<html>
2
+<head>
3
+ <title>Model Configuration</title>
4
+</head>
5
+
6
+<body>
7
+ <div x-data="{
8
+ _fns: null,
9
+ chatProviders: [],
10
+ embeddingProviders: [],
11
+ apiKeyStatus: {},
12
+ apiKeyValues: {},
13
+ providersReady: false,
14
+ _debounceTimers: {},
15
+ modelSections: [],
16
+ getProviders(key) { return key === 'embedding_model' ? this.embeddingProviders : this.chatProviders; },
17
+ getSearchType(key) { return key === 'embedding_model' ? 'embedding' : 'chat'; },
18
+ onApiKeyInput(provider) {
19
+ clearTimeout(this._debounceTimers['ak_' + provider]);
20
+ this._debounceTimers['ak_' + provider] = setTimeout(() => this._saveKey(provider), 800);
21
+ },
22
+ async _saveKey(provider) {
23
+ if (!this.apiKeyValues[provider] || !this._fns) return;
24
+ try {
25
+ await this._fns.saveApiKey(provider, this.apiKeyValues[provider]);
26
+ this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: true };
27
+ } catch (e) { console.error('Failed to save API key:', e); }
28
+ }
29
+ }"
30
+ x-init="
31
+ _fns = await import('/plugins/_model_config/webui/config-store.js');
32
+ modelSections = _fns.MODEL_SECTIONS;
33
+ try {
34
+ await _fns.initConfigComponent($data, config);
35
+ } catch (e) { console.error('Config init failed:', e); }
36
+ providersReady = true;
37
+ ">
38
+ <template x-if="config && providersReady">
39
+ <div class="model-config-sections">
40
+
41
+ <!-- Per-Chat Override -->
42
+ <div class="model-section">
43
+ <div class="section-title">Per-Chat Override</div>
44
+ <div class="section-description">Enable per-chat model switching via the model switcher in the chat area.</div>
45
+
46
+ <div class="field">
47
+ <div class="field-label">
48
+ <div class="field-title">Enable model switcher</div>
49
+ <div class="field-description">Show a model selection dropdown in the chat area. Overrides main and utility model for individual chats.</div>
50
+ </div>
51
+ <div class="field-control">
52
+ <label class="toggle">
53
+ <input type="checkbox" x-model="config.chat_model.allow_chat_override" />
54
+ <span class="toggler"></span>
55
+ </label>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Model Presets -->
60
+ <div x-show="config.chat_model.allow_chat_override" class="presets-section">
61
+ <div class="preset-section-header">
62
+ <div class="field-title">Model Presets</div>
63
+ <div class="field-description">Predefined model configurations for quick switching. Each preset defines a main model and an optional utility model override.</div>
64
+ </div>
65
+
66
+ <template x-for="(preset, idx) in (config.model_presets || [])" :key="idx">
67
+ <div class="preset-card" x-data="{ expanded: false }">
68
+ <div class="preset-card-header" @click="expanded = !expanded">
69
+ <span class="material-symbols-outlined preset-expand-icon"
70
+ :style="expanded ? 'transform:rotate(90deg)' : ''"
71
+ style="font-size:16px; transition:transform 0.15s ease;">chevron_right</span>
72
+ <span class="preset-card-name" x-text="preset.name || '(unnamed)'"></span>
73
+ <span class="preset-card-summary" x-show="!expanded"
74
+ x-text="(preset.chat?.provider ? preset.chat.provider + '/' : '') + (preset.chat?.name || '')"></span>
75
+ <button class="text-button preset-delete-btn"
76
+ @click.stop="config.model_presets = config.model_presets.filter((_, i) => i !== idx)"
77
+ title="Remove preset">
78
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
79
+ </button>
80
+ </div>
81
+
82
+ <div class="preset-card-body" x-show="expanded" x-transition.opacity>
83
+ <div class="field">
84
+ <div class="field-label">
85
+ <div class="field-title">Preset name</div>
86
+ <div class="field-description">Display name shown in the model switcher dropdown.</div>
87
+ </div>
88
+ <div class="field-control"><input type="text" x-model="preset.name" placeholder="e.g. GPT-4o, Claude Sonnet" /></div>
89
+ </div>
90
+
91
+ <div class="preset-subheader">Main Model</div>
92
+ <div class="field">
93
+ <div class="field-label">
94
+ <div class="field-title">Provider</div>
95
+ <div class="field-description">LLM service provider for this preset's main model.</div>
96
+ </div>
97
+ <div class="field-control">
98
+ <select x-model="preset.chat.provider"
99
+ x-effect="$nextTick(() => { if (chatProviders.length) $el.value = preset.chat.provider })">
100
+ <option value="">— select —</option>
101
+ <template x-for="p in chatProviders" :key="p.value">
102
+ <option :value="p.value" x-text="p.label"></option>
103
+ </template>
104
+ </select>
105
+ </div>
106
+ </div>
107
+ <div class="field">
108
+ <div class="field-label">
109
+ <div class="field-title">Model name</div>
110
+ <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
111
+ </div>
112
+ <div class="field-control" style="position:relative;"
113
+ x-data="{ results: [], open: false, searching: false,
114
+ doSearch() { this.searching = true; _fns.searchModels(preset.chat.provider, preset.chat.name, 'chat', preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
115
+ }"
116
+ @click.outside="open = false">
117
+ <input type="text" x-model="preset.chat.name" style="padding-right:32px;"
118
+ @keydown.enter.prevent="doSearch()" />
119
+ <span class="model-search-btn"
120
+ @click="if (!searching) doSearch()"
121
+ title="Search available models">
122
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
123
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
124
+ </span>
125
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
126
+ <template x-for="m in results" :key="m">
127
+ <div class="model-search-item" @click="preset.chat.name = m; open = false;" x-text="m"></div>
128
+ </template>
129
+ </div>
130
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
131
+ <div class="model-search-item disabled">No models found</div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ <div class="field">
136
+ <div class="field-label">
137
+ <div class="field-title">API key</div>
138
+ <div class="field-description">Leave empty to use the global API key for this provider.</div>
139
+ </div>
140
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
141
+ <input :type="showKey ? 'text' : 'password'" x-model="preset.chat.api_key" autocomplete="off"
142
+ :placeholder="apiKeyStatus[preset.chat.provider] ? '••••••••••••' : ''"
143
+ style="padding-right:32px;" />
144
+ <span class="material-symbols-outlined eye-toggle"
145
+ @click="
146
+ showKey = !showKey;
147
+ if (showKey && !preset.chat.api_key && apiKeyStatus[preset.chat.provider]) {
148
+ _fns.revealApiKey(preset.chat.provider).then(v => { if (v) { preset.chat.api_key = v; _revealed = v; } });
149
+ }
150
+ if (!showKey && _revealed && preset.chat.api_key === _revealed) {
151
+ preset.chat.api_key = ''; _revealed = '';
152
+ }
153
+ "
154
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
155
+ </div>
156
+ </div>
157
+ <div class="field">
158
+ <div class="field-label">
159
+ <div class="field-title">API base URL</div>
160
+ <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
161
+ </div>
162
+ <div class="field-control"><input type="text" x-model="preset.chat.api_base" /></div>
163
+ </div>
164
+
165
+ <div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional — falls back to global Utility Model)</span></div>
166
+ <div class="field">
167
+ <div class="field-label">
168
+ <div class="field-title">Provider</div>
169
+ <div class="field-description">Leave empty to use the global Utility Model provider.</div>
170
+ </div>
171
+ <div class="field-control">
172
+ <select x-model="preset.utility.provider"
173
+ x-effect="$nextTick(() => { if (chatProviders.length) $el.value = preset.utility.provider })">
174
+ <option value="">— default —</option>
175
+ <template x-for="p in chatProviders" :key="p.value">
176
+ <option :value="p.value" x-text="p.label"></option>
177
+ </template>
178
+ </select>
179
+ </div>
180
+ </div>
181
+ <div class="field">
182
+ <div class="field-label">
183
+ <div class="field-title">Model name</div>
184
+ <div class="field-description">Model for background tasks. Leave empty to use the global Utility Model.</div>
185
+ </div>
186
+ <div class="field-control" style="position:relative;"
187
+ x-data="{ results: [], open: false, searching: false,
188
+ doSearch() { this.searching = true; _fns.searchModels(preset.utility.provider || preset.chat.provider, preset.utility.name, 'chat', preset.utility.api_base || preset.chat.api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
189
+ }"
190
+ @click.outside="open = false">
191
+ <input type="text" x-model="preset.utility.name" placeholder="Leave empty for default" style="padding-right:32px;"
192
+ @keydown.enter.prevent="doSearch()" />
193
+ <span class="model-search-btn"
194
+ @click="if (!searching) doSearch()"
195
+ title="Search available models">
196
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
197
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
198
+ </span>
199
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
200
+ <template x-for="m in results" :key="m">
201
+ <div class="model-search-item" @click="preset.utility.name = m; open = false;" x-text="m"></div>
202
+ </template>
203
+ </div>
204
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
205
+ <div class="model-search-item disabled">No models found</div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ <div class="field">
210
+ <div class="field-label">
211
+ <div class="field-title">API key</div>
212
+ <div class="field-description">Leave empty to use the global API key for this provider.</div>
213
+ </div>
214
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false, _revealed: '' }">
215
+ <input :type="showKey ? 'text' : 'password'" x-model="preset.utility.api_key" autocomplete="off"
216
+ :placeholder="apiKeyStatus[preset.utility.provider || preset.chat.provider] ? '••••••••••••' : ''"
217
+ style="padding-right:32px;" />
218
+ <span class="material-symbols-outlined eye-toggle"
219
+ @click="
220
+ const prov = preset.utility.provider || preset.chat.provider;
221
+ showKey = !showKey;
222
+ if (showKey && !preset.utility.api_key && apiKeyStatus[prov]) {
223
+ _fns.revealApiKey(prov).then(v => { if (v) { preset.utility.api_key = v; _revealed = v; } });
224
+ }
225
+ if (!showKey && _revealed && preset.utility.api_key === _revealed) {
226
+ preset.utility.api_key = ''; _revealed = '';
227
+ }
228
+ "
229
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
230
+ </div>
231
+ </div>
232
+ <div class="field">
233
+ <div class="field-label">
234
+ <div class="field-title">API base URL</div>
235
+ <div class="field-description">Custom endpoint URL. Leave empty for the provider's default.</div>
236
+ </div>
237
+ <div class="field-control"><input type="text" x-model="preset.utility.api_base" /></div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </template>
242
+
243
+ <button class="text-button preset-add-btn"
244
+ @click="
245
+ if (!config.model_presets) config.model_presets = [];
246
+ config.model_presets = [...config.model_presets, {
247
+ name: '',
248
+ chat: { provider: config.chat_model.provider || '', name: config.chat_model.name || '', api_key: '', api_base: config.chat_model.api_base || '' },
249
+ utility: { provider: '', name: '', api_key: '', api_base: '' }
250
+ }]">
251
+ <span class="material-symbols-outlined" style="font-size:16px;">add</span>
252
+ <span>Add Preset</span>
253
+ </button>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- Model Sections (Main, Utility, Embedding) -->
258
+ <template x-for="section in modelSections" :key="section.key">
259
+ <div class="model-section">
260
+ <div class="section-title" x-text="section.title"></div>
261
+ <div class="section-description" x-text="section.desc"></div>
262
+
263
+ <!-- Provider -->
264
+ <div class="field">
265
+ <div class="field-label">
266
+ <div class="field-title">Provider</div>
267
+ <div class="field-description">LLM service provider for this model slot.</div>
268
+ </div>
269
+ <div class="field-control">
270
+ <select x-model="config[section.key].provider"
271
+ x-effect="$nextTick(() => { if (getProviders(section.key).length) $el.value = config[section.key].provider })">
272
+ <template x-for="p in getProviders(section.key)" :key="p.value">
273
+ <option :value="p.value" x-text="p.label"></option>
274
+ </template>
275
+ </select>
276
+ </div>
277
+ </div>
278
+
279
+ <!-- Model name + search -->
280
+ <div class="field">
281
+ <div class="field-label">
282
+ <div class="field-title">Model name</div>
283
+ <div class="field-description">Model identifier. Click the search icon to browse available models.</div>
284
+ </div>
285
+ <div class="field-control" style="position:relative;"
286
+ x-data="{ results: [], open: false, searching: false,
287
+ doSearch() { this.searching = true; _fns.searchModels(config[section.key].provider, config[section.key].name, getSearchType(section.key), config[section.key].api_base).then(r => { this.results = r; this.open = true; }).finally(() => this.searching = false); }
288
+ }"
289
+ @click.outside="open = false">
290
+ <input type="text" x-model="config[section.key].name" style="padding-right:32px;"
291
+ @keydown.enter.prevent="doSearch()" />
292
+ <span class="model-search-btn"
293
+ @click="if (!searching) doSearch()"
294
+ title="Search available models">
295
+ <span class="material-symbols-outlined" :style="searching && 'opacity:0'">search</span>
296
+ <span class="material-symbols-outlined model-search-spinner" :style="!searching && 'opacity:0'">progress_activity</span>
297
+ </span>
298
+ <div class="model-search-results" x-show="open && results.length > 0" x-transition.opacity>
299
+ <template x-for="m in results" :key="m">
300
+ <div class="model-search-item" @click="config[section.key].name = m; open = false;" x-text="m"></div>
301
+ </template>
302
+ </div>
303
+ <div class="model-search-results" x-show="open && results.length === 0 && !searching">
304
+ <div class="model-search-item disabled">No models found</div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+
309
+ <!-- API key + eye toggle -->
310
+ <div class="field">
311
+ <div class="field-label">
312
+ <div class="field-title">API key</div>
313
+ <div class="field-description">Authentication key for this provider. Shared across all model slots using the same provider.</div>
314
+ </div>
315
+ <div class="field-control" style="position:relative;" x-data="{ showKey: false }">
316
+ <input :type="showKey ? 'text' : 'password'"
317
+ x-model="apiKeyValues[config[section.key].provider]"
318
+ :placeholder="apiKeyStatus[config[section.key].provider] ? '••••••••••••' : ''"
319
+ autocomplete="off"
320
+ @input="onApiKeyInput(config[section.key].provider)"
321
+ style="padding-right:32px;" />
322
+ <span class="material-symbols-outlined eye-toggle"
323
+ @click="
324
+ showKey = !showKey;
325
+ const prov = config[section.key].provider;
326
+ if (showKey && !apiKeyValues[prov] && apiKeyStatus[prov]) {
327
+ _fns.revealApiKey(prov).then(v => { if (v) apiKeyValues[prov] = v; });
328
+ }
329
+ "
330
+ x-text="showKey ? 'visibility' : 'visibility_off'"></span>
331
+ </div>
332
+ </div>
333
+
334
+ <!-- API base URL -->
335
+ <div class="field">
336
+ <div class="field-label">
337
+ <div class="field-title">API base URL</div>
338
+ <div class="field-description">
339
+ Custom endpoint URL. Leave empty to use the provider's default.
340
+ </div>
341
+ </div>
342
+ <div class="field-control">
343
+ <input type="text" x-model="config[section.key].api_base" />
344
+ </div>
345
+ </div>
346
+
347
+ <!-- Context length -->
348
+ <template x-if="section.key !== 'embedding_model'">
349
+ <div class="field">
350
+ <div class="field-label">
351
+ <div class="field-title">Context length</div>
352
+ <div class="field-description">
353
+ Maximum number of tokens in the context window. System prompt, chat history, RAG and response all count towards this limit.
354
+ </div>
355
+ </div>
356
+ <div class="field-control">
357
+ <input type="number" x-model.number="config[section.key].ctx_length" />
358
+ </div>
359
+ </div>
360
+ </template>
361
+
362
+ <!-- Context history -->
363
+ <template x-if="section.key === 'chat_model'">
364
+ <div>
365
+ <div class="field">
366
+ <div class="field-label">
367
+ <div class="field-title">Context window space for chat history</div>
368
+ <div class="field-description">
369
+ Portion of context window dedicated to chat history visible to the agent. Smaller size will result in shorter and more summarized history.
370
+ </div>
371
+ </div>
372
+ <div class="field-control">
373
+ <input type="range" min="0.01" max="1" step="0.01" x-model.number="config.chat_model.ctx_history" />
374
+ <span class="range-value" x-text="config.chat_model.ctx_history"></span>
375
+ </div>
376
+ </div>
377
+ <div class="field">
378
+ <div class="field-label">
379
+ <div class="field-title">Supports Vision</div>
380
+ <div class="field-description">
381
+ Models capable of Vision can for example natively see the content of image attachments.
382
+ </div>
383
+ </div>
384
+ <div class="field-control">
385
+ <label class="toggle">
386
+ <input type="checkbox" x-model="config.chat_model.vision" />
387
+ <span class="toggler"></span>
388
+ </label>
389
+ </div>
390
+ </div>
391
+ </div>
392
+ </template>
393
+
394
+ <!-- Context input slider -->
395
+ <template x-if="section.key === 'utility_model'">
396
+ <div class="field">
397
+ <div class="field-label">
398
+ <div class="field-title">Context window space for utility model input</div>
399
+ <div class="field-description">Portion of context window used for utility model input messages.</div>
400
+ </div>
401
+ <div class="field-control">
402
+ <input type="range" min="0.01" max="1" step="0.01" x-model.number="config.utility_model.ctx_input" />
403
+ <span class="range-value" x-text="config.utility_model.ctx_input"></span>
404
+ </div>
405
+ </div>
406
+ </template>
407
+
408
+ <!-- Rate limits -->
409
+ <div class="field">
410
+ <div class="field-label">
411
+ <div class="field-title">Requests per minute limit</div>
412
+ <div class="field-description">
413
+ Limits the number of requests per minute. Waits if the limit is exceeded. Set to 0 to disable.
414
+ </div>
415
+ </div>
416
+ <div class="field-control">
417
+ <input type="number" x-model.number="config[section.key].rl_requests" />
418
+ </div>
419
+ </div>
420
+
421
+ <div class="field">
422
+ <div class="field-label">
423
+ <div class="field-title">Input tokens per minute limit</div>
424
+ <div class="field-description">
425
+ Limits the number of input tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.
426
+ </div>
427
+ </div>
428
+ <div class="field-control">
429
+ <input type="number" x-model.number="config[section.key].rl_input" />
430
+ </div>
431
+ </div>
432
+
433
+ <template x-if="section.key !== 'embedding_model'">
434
+ <div class="field">
435
+ <div class="field-label">
436
+ <div class="field-title">Output tokens per minute limit</div>
437
+ <div class="field-description">
438
+ Limits the number of output tokens per minute. Waits if the limit is exceeded. Set to 0 to disable.
439
+ </div>
440
+ </div>
441
+ <div class="field-control">
442
+ <input type="number" x-model.number="config[section.key].rl_output" />
443
+ </div>
444
+ </div>
445
+ </template>
446
+
447
+ <!-- Additional parameters -->
448
+ <div class="field field-full">
449
+ <div class="field-label">
450
+ <div class="field-title">Additional parameters</div>
451
+ <div class="field-description">
452
+ Any other parameters supported by <a href='https://docs.litellm.ai/docs/set_keys' target='_blank'>LiteLLM</a>. Format is KEY=VALUE on individual lines. Value can be JSON objects; unquoted is treated as object/number, quoted as string.
453
+ </div>
454
+ </div>
455
+ <div class="field-control">
456
+ <textarea x-model="config[section.key]._kwargs_text"
457
+ @change="config[section.key].kwargs = _fns.textToKwargs(config[section.key]._kwargs_text)"></textarea>
458
+ </div>
459
+ </div>
460
+
461
+ <!-- Browser HTTP Headers -->
462
+ <template x-if="section.key === 'chat_model'">
463
+ <div class="field field-full">
464
+ <div class="field-label">
465
+ <div class="field-title">Browser HTTP Headers</div>
466
+ <div class="field-description">
467
+ Custom HTTP headers sent with browser requests. The browser agent uses the main model. Format is KEY=VALUE, one per line.
468
+ </div>
469
+ </div>
470
+ <div class="field-control">
471
+ <textarea x-model="config._browser_headers_text"
472
+ @change="config.browser_http_headers = _fns.textToHeaders(config._browser_headers_text)"></textarea>
473
+ </div>
474
+ </div>
475
+ </template>
476
+
477
+ </div>
478
+ </template>
479
+
480
+ </div>
481
+ </template>
482
+ </div>
483
+
484
+<style>
485
+ .model-config-sections {
486
+ display: flex;
487
+ flex-direction: column;
488
+ gap: 16px;
489
+ }
490
+ .model-section {
491
+ border: 1px solid var(--color-border);
492
+ border-radius: 8px;
493
+ padding: 16px;
494
+ }
495
+ .model-section .section-title {
496
+ margin-top: 0;
497
+ }
498
+ .eye-toggle {
499
+ position: absolute;
500
+ right: 8px;
501
+ top: 50%;
502
+ transform: translateY(-50%);
503
+ font-size: 18px;
504
+ cursor: pointer;
505
+ user-select: none;
506
+ opacity: 0.6;
507
+ z-index: 1;
508
+ }
509
+ .eye-toggle:hover {
510
+ opacity: 1;
511
+ }
512
+ /* Preset management */
513
+ .presets-section {
514
+ margin-top: 12px;
515
+ padding-top: 12px;
516
+ border-top: 1px solid var(--color-border);
517
+ }
518
+ .preset-section-header {
519
+ margin-bottom: 10px;
520
+ }
521
+ .preset-card {
522
+ border: 1px solid var(--color-border);
523
+ border-radius: 6px;
524
+ margin-bottom: 8px;
525
+ overflow: hidden;
526
+ }
527
+ .preset-card-header {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: 6px;
531
+ padding: 8px 10px;
532
+ cursor: pointer;
533
+ font-size: 0.85rem;
534
+ }
535
+ .preset-card-header:hover {
536
+ background: var(--color-background-hover, rgba(255,255,255,0.04));
537
+ }
538
+ .preset-card-name {
539
+ font-weight: 500;
540
+ }
541
+ .preset-card-summary {
542
+ flex: 1;
543
+ text-align: right;
544
+ opacity: 0.5;
545
+ font-size: 0.75rem;
546
+ overflow: hidden;
547
+ text-overflow: ellipsis;
548
+ white-space: nowrap;
549
+ }
550
+ .preset-delete-btn {
551
+ margin-left: auto;
552
+ opacity: 0.5;
553
+ padding: 2px !important;
554
+ }
555
+ .preset-delete-btn:hover {
556
+ opacity: 1;
557
+ color: var(--color-error, #f44) !important;
558
+ }
559
+ .preset-card-body {
560
+ padding: 4px 12px 12px;
561
+ border-top: 1px solid var(--color-border);
562
+ }
563
+ .preset-subheader {
564
+ font-size: 0.8rem;
565
+ font-weight: 500;
566
+ opacity: 0.7;
567
+ margin: 10px 0 4px;
568
+ padding-top: 8px;
569
+ border-top: 1px dashed var(--color-border);
570
+ }
571
+ .preset-add-btn {
572
+ margin-top: 4px;
573
+ }
574
+ /* Model search */
575
+ .model-search-btn {
576
+ position: absolute;
577
+ right: 8px;
578
+ top: 50%;
579
+ transform: translateY(-50%);
580
+ width: 20px;
581
+ height: 20px;
582
+ display: grid;
583
+ place-items: center;
584
+ cursor: pointer;
585
+ user-select: none;
586
+ opacity: 0.6;
587
+ z-index: 1;
588
+ }
589
+ .model-search-btn:hover {
590
+ opacity: 1;
591
+ }
592
+ .model-search-btn > span {
593
+ grid-area: 1 / 1;
594
+ font-size: 18px;
595
+ transition: opacity 0.15s;
596
+ }
597
+ .model-search-spinner {
598
+ animation: spin 0.8s linear infinite;
599
+ }
600
+ @keyframes spin {
601
+ from { transform: rotate(0deg); }
602
+ to { transform: rotate(360deg); }
603
+ }
604
+ .model-search-results {
605
+ position: absolute;
606
+ top: calc(100% + 4px);
607
+ left: 0;
608
+ right: 0;
609
+ max-height: 200px;
610
+ overflow-y: auto;
611
+ background: var(--color-input);
612
+ border: 1px solid var(--color-border);
613
+ border-radius: 6px;
614
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
615
+ z-index: 50;
616
+ padding: 4px;
617
+ }
618
+ .model-search-item {
619
+ padding: 5px 8px;
620
+ font-size: 0.8rem;
621
+ border-radius: 4px;
622
+ cursor: pointer;
623
+ word-break: break-all;
624
+ }
625
+ .model-search-item:hover {
626
+ background: var(--color-background-hover, rgba(255,255,255,0.06));
627
+ }
628
+ .model-search-item.disabled {
629
+ opacity: 0.4;
630
+ cursor: default;
631
+ font-style: italic;
632
+ }
633
+ .model-search-item.disabled:hover {
634
+ background: transparent;
635
+ }
636
+</style>
637
+</body>
638
+</html>