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="">&#x2014; select &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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 &#x2014; 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="">&#x2014; default &#x2014;</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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
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>