From 3800a09613e2a0b6c218464f22aab617a07d6ed6 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:38:03 +0800 Subject: [PATCH] feat: support custom LLM provider configuration via web UI Users can now define custom LLM providers through the web UI and have them take effect without modifying environment variables or config files. - Add `CustomLlmProviderSettings` struct and `llm_custom_providers` field to `Settings` so custom provider definitions are persisted and loaded from the DB settings table - Add `LlmConfig::resolve_custom_provider()` to build a `RegistryProviderConfig` from user-defined provider data (base_url, adapter, model, api_key) - Flip resolution priority to `db > env > default` so active provider set through the UI takes precedence over deployment env vars - Warn when a custom provider is missing base_url or model - Add startup info logs for backend source and provider creation - Add regression tests for custom provider resolution and DB priority --- src/channels/web/handlers/settings.rs | 62 +++++++ src/channels/web/static/app.js | 239 +++++++++++++++++++++++++ src/channels/web/static/i18n/en.js | 29 ++++ src/channels/web/static/i18n/zh-CN.js | 29 ++++ src/channels/web/static/index.html | 58 +++++++ src/channels/web/static/style.css | 240 ++++++++++++++++++++++++++ src/config/llm.rs | 150 +++++++++++++++- src/llm/mod.rs | 2 + src/settings.rs | 27 +++ 9 files changed, 829 insertions(+), 7 deletions(-) diff --git a/src/channels/web/handlers/settings.rs b/src/channels/web/handlers/settings.rs index dd66027b..a7749daa 100644 --- a/src/channels/web/handlers/settings.rs +++ b/src/channels/web/handlers/settings.rs @@ -68,6 +68,14 @@ pub async fn settings_set_handler( .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + + // Guard: cannot remove a custom provider that is currently active. + if key == "llm_custom_providers" { + if let Err(status) = guard_active_provider_not_removed(store, &state.user_id, &body.value).await { + return Err(status); + } + } + store .set_setting(&state.user_id, &key, &body.value) .await @@ -79,6 +87,60 @@ pub async fn settings_set_handler( Ok(StatusCode::NO_CONTENT) } +/// Returns `Err(409)` if the active `llm_backend` is a custom provider that +/// would be removed by the incoming update to `llm_custom_providers`. +async fn guard_active_provider_not_removed( + store: &Arc, + user_id: &str, + new_value: &serde_json::Value, +) -> Result<(), StatusCode> { + // Get the currently active backend. + let active_backend = match store.get_setting(user_id, "llm_backend").await { + Ok(Some(v)) => match v.as_str() { + Some(s) if !s.is_empty() => s.to_string(), + _ => return Ok(()), + }, + _ => return Ok(()), + }; + + // Parse the incoming provider list. + let new_providers: Vec = match new_value.as_array() { + Some(arr) => arr.clone(), + None => return Ok(()), + }; + + // Check whether the active backend exists in the OLD custom providers list. + let old_providers_value = match store.get_setting(user_id, "llm_custom_providers").await { + Ok(Some(v)) => v, + _ => return Ok(()), + }; + let old_providers: Vec = match old_providers_value.as_array() { + Some(arr) => arr.clone(), + None => return Ok(()), + }; + + let active_was_custom = old_providers.iter().any(|p| { + p.get("id").and_then(|v| v.as_str()) == Some(&active_backend) + }); + if !active_was_custom { + return Ok(()); + } + + // Reject if the active provider is absent from the new list. + let still_present = new_providers.iter().any(|p| { + p.get("id").and_then(|v| v.as_str()) == Some(&active_backend) + }); + if !still_present { + tracing::warn!( + active_backend = %active_backend, + "Rejected attempt to delete the active custom LLM provider" + ); + return Err(StatusCode::CONFLICT); + } + + Ok(()) +} + pub async fn settings_delete_handler( State(state): State>, Path(key): Path, diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 9d931500..4129f9a5 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -1884,6 +1884,7 @@ function switchTab(tab) { stopPairingPoll(); } if (tab === 'skills') loadSkills(); + if (tab === 'config') loadConfig(); } // --- Memory (filesystem tree) --- @@ -4679,9 +4680,247 @@ document.addEventListener('click', function(e) { case 'switch-language': if (typeof switchLanguage === 'function') switchLanguage(el.dataset.lang); break; + case 'set-active-provider': + setActiveProvider(el.dataset.id); + break; + case 'delete-custom-provider': + deleteCustomProvider(el.dataset.id); + break; } }); document.getElementById('language-btn').addEventListener('click', function() { if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu(); }); + +// --- Config Tab --- + +// Like apiFetch but for endpoints that return 204 No Content +function apiFetchVoid(path, options) { + const opts = options || {}; + opts.headers = opts.headers || {}; + opts.headers['Authorization'] = 'Bearer ' + token; + if (opts.body && typeof opts.body === 'object') { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(opts.body); + } + return fetch(path, opts).then((res) => { + if (!res.ok) { + return res.text().then((body) => { throw new Error(body || (res.status + ' ' + res.statusText)); }); + } + }); +} + +// Generated from providers.json + nearai/bedrock (handled separately in llm.rs) +const BUILTIN_PROVIDERS = [ + { id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://api.near.ai/v1', builtin: true }, + { id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true }, + { id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true }, + { id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true }, + { id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true }, + { id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true }, + { id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true }, + { id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true }, + { id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true }, + { id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true }, + { id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true }, + { id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true }, + { id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true }, + { id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true }, + { id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true }, + { id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true }, + { id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true }, + { id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true }, + { id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true }, + { id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true }, + { id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true }, + { id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true }, + { id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true }, +]; + +const ADAPTER_LABELS = { + open_ai_completions: 'OpenAI Compatible', + anthropic: 'Anthropic', + ollama: 'Ollama', + bedrock: 'AWS Bedrock', + nearai: 'NEAR AI', +}; + +let _customProviders = []; +let _activeLlmBackend = ''; +let _configLoaded = false; + +function loadConfig() { + const list = document.getElementById('providers-list'); + list.innerHTML = '
' + I18n.t('common.loading') + '
'; + + apiFetch('/api/settings/export').then((d) => { + const s = (d && d.settings) ? d.settings : {}; + _activeLlmBackend = s['llm_backend'] ? String(s['llm_backend']) : 'nearai'; + try { + const val = s['llm_custom_providers']; + _customProviders = Array.isArray(val) ? val : (val ? JSON.parse(val) : []); + } catch (e) { + _customProviders = []; + } + _configLoaded = true; + renderProviders(); + }).catch(() => { + _activeLlmBackend = 'nearai'; + _customProviders = []; + _configLoaded = true; + renderProviders(); + }); +} + +function renderProviders() { + const list = document.getElementById('providers-list'); + const allProviders = [...BUILTIN_PROVIDERS, ..._customProviders].sort((a, b) => { + if (a.id === _activeLlmBackend) return -1; + if (b.id === _activeLlmBackend) return 1; + return 0; + }); + + if (allProviders.length === 0) { + list.innerHTML = '
No providers
'; + return; + } + + list.innerHTML = allProviders.map((p) => { + const isActive = p.id === _activeLlmBackend; + const adapterLabel = ADAPTER_LABELS[p.adapter] || p.adapter; + const activeBadge = isActive + ? '' + I18n.t('status.active') + '' + : ''; + const builtinBadge = p.builtin + ? '' + I18n.t('config.builtin') + '' + : ''; + const deleteBtn = !p.builtin && !isActive + ? '' + : ''; + const useBtn = !isActive + ? '' + : ''; + const baseUrlText = p.base_url + ? '' + escHtml(p.base_url) + '' + : ''; + + return '
' + + '
' + + '' + escHtml(p.name || p.id) + '' + + '' + escHtml(p.id) + '' + + activeBadge + builtinBadge + + '
' + + '
' + + '' + escHtml(adapterLabel) + '' + + baseUrlText + + '
' + + '
' + + useBtn + deleteBtn + + '
' + + '
'; + }).join(''); +} + +function escHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function setActiveProvider(id) { + apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } }) + .then(() => apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' })) + .then(() => { + _activeLlmBackend = id; + renderProviders(); + document.getElementById('providers-list').scrollIntoView({ behavior: 'smooth', block: 'start' }); + document.getElementById('config-restart-notice').style.display = 'flex'; + showToast(I18n.t('config.providerActivated', { name: id })); + }) + .catch((e) => showToast(I18n.t('error.unknown') + ': ' + e.message, 'error')); +} + +function deleteCustomProvider(id) { + if (id === _activeLlmBackend) { + showToast(I18n.t('config.cannotDeleteActiveProvider'), 'error'); + return; + } + if (!confirm(I18n.t('config.confirmDeleteProvider', { id }))) return; + _customProviders = _customProviders.filter((p) => p.id !== id); + saveCustomProviders().then(() => { + renderProviders(); + showToast(I18n.t('config.providerDeleted')); + }); +} + +function saveCustomProviders() { + return apiFetchVoid('/api/settings/llm_custom_providers', { method: 'PUT', body: { value: _customProviders } }); +} + +// Add provider form + +document.getElementById('add-provider-btn').addEventListener('click', () => { + document.getElementById('add-provider-form').style.display = ''; + document.getElementById('add-provider-btn').style.display = 'none'; + document.getElementById('provider-name').focus(); +}); + +document.getElementById('cancel-provider-btn').addEventListener('click', () => { + resetProviderForm(); +}); + +document.getElementById('save-provider-btn').addEventListener('click', () => { + const name = document.getElementById('provider-name').value.trim(); + const id = document.getElementById('provider-id').value.trim(); + const adapter = document.getElementById('provider-adapter').value; + const baseUrl = document.getElementById('provider-base-url').value.trim(); + const apiKey = document.getElementById('provider-api-key').value.trim(); + const model = document.getElementById('provider-model').value.trim(); + + if (!id || !name) { + showToast(I18n.t('config.providerFieldsRequired'), 'error'); + return; + } + if (!/^[a-z0-9_-]+$/.test(id)) { + showToast(I18n.t('config.providerIdInvalid'), 'error'); + return; + } + const allIds = [...BUILTIN_PROVIDERS.map((p) => p.id), ..._customProviders.map((p) => p.id)]; + if (allIds.includes(id)) { + showToast(I18n.t('config.providerIdTaken', { id }), 'error'); + return; + } + + const newProvider = { id, name, adapter, base_url: baseUrl, default_model: model, api_key: apiKey || undefined, builtin: false }; + _customProviders.push(newProvider); + + saveCustomProviders().then(() => { + renderProviders(); + resetProviderForm(); + document.getElementById('config-restart-notice').style.display = 'flex'; + showToast(I18n.t('config.providerAdded', { name })); + }).catch((e) => { + _customProviders.pop(); + showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'); + }); +}); + +function resetProviderForm() { + document.getElementById('add-provider-form').style.display = 'none'; + document.getElementById('add-provider-btn').style.display = ''; + ['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => { + document.getElementById(id).value = ''; + }); + document.getElementById('provider-adapter').selectedIndex = 0; +} + +// Auto-fill provider ID from name +document.getElementById('provider-name').addEventListener('input', (e) => { + const idField = document.getElementById('provider-id'); + if (!idField.dataset.edited) { + idField.value = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + } +}); + +document.getElementById('provider-id').addEventListener('input', (e) => { + e.target.dataset.edited = e.target.value ? '1' : ''; +}); diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 49bec762..1917fd26 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -31,6 +31,7 @@ I18n.register('en', { 'tab.routines': 'Routines', 'tab.extensions': 'Extensions', 'tab.skills': 'Skills', + 'tab.config': 'Config', 'tab.logs': 'Logs', // Status @@ -340,6 +341,34 @@ I18n.register('en', { 'ext.removed': 'Removed {name}', 'ext.installFailed': 'Install failed: {message}', + // Config Tab — Model Providers + 'config.modelProviders': 'Model Providers', + 'config.addProvider': '+ Add Provider', + 'config.newProvider': 'New Provider', + 'config.restartNotice': 'Changes take effect after restart.', + 'config.builtin': 'built-in', + 'config.useProvider': 'Use', + 'config.providerName': 'Display Name', + 'config.providerNamePlaceholder': 'My Provider', + 'config.providerId': 'Provider ID', + 'config.providerIdPlaceholder': 'my-provider', + 'config.providerIdHint': 'Lowercase letters, numbers, hyphens', + 'config.providerAdapter': 'API Adapter', + 'config.adapterOpenAI': 'OpenAI Compatible', + 'config.adapterAnthropic': 'Anthropic', + 'config.adapterOllama': 'Ollama', + 'config.providerBaseUrl': 'Base URL', + 'config.providerApiKey': 'API Key', + 'config.providerModel': 'Default Model', + 'config.providerActivated': 'Switched to {name} (restart to apply)', + 'config.providerAdded': 'Added provider "{name}" (restart to apply)', + 'config.providerDeleted': 'Provider deleted', + 'config.confirmDeleteProvider': 'Delete provider "{id}"?', + 'config.cannotDeleteActiveProvider': 'Cannot delete the active provider. Switch to another provider first.', + 'config.providerFieldsRequired': 'Display name and Provider ID are required', + 'config.providerIdInvalid': 'Provider ID: use only lowercase letters, numbers, hyphens', + 'config.providerIdTaken': 'Provider ID "{id}" is already taken', + // Configure 'config.title': 'Configure {name}', 'config.telegramOwnerHint': 'After saving, IronClaw will show a one-time code. Send `/start CODE` to your bot in Telegram and IronClaw will finish setup automatically.', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index d31cc0df..8f715ee7 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -31,6 +31,7 @@ I18n.register('zh-CN', { 'tab.routines': '定时任务', 'tab.extensions': '扩展', 'tab.skills': '技能', + 'tab.config': '配置', 'tab.logs': '日志', // 状态 @@ -340,6 +341,34 @@ I18n.register('zh-CN', { 'ext.removed': '已移除 {name}', 'ext.installFailed': '安装失败: {message}', + // 配置页 — 模型提供商 + 'config.modelProviders': '模型提供商', + 'config.addProvider': '+ 添加提供商', + 'config.newProvider': '新建提供商', + 'config.restartNotice': '更改将在重启后生效。', + 'config.builtin': '内置', + 'config.useProvider': '使用', + 'config.providerName': '显示名称', + 'config.providerNamePlaceholder': '我的提供商', + 'config.providerId': '提供商 ID', + 'config.providerIdPlaceholder': 'my-provider', + 'config.providerIdHint': '小写字母、数字、连字符', + 'config.providerAdapter': 'API 适配器', + 'config.adapterOpenAI': 'OpenAI 兼容', + 'config.adapterAnthropic': 'Anthropic', + 'config.adapterOllama': 'Ollama', + 'config.providerBaseUrl': '基础 URL', + 'config.providerApiKey': 'API 密钥', + 'config.providerModel': '默认模型', + 'config.providerActivated': '已切换到 {name}(重启后生效)', + 'config.providerAdded': '已添加提供商 "{name}"(重启后生效)', + 'config.providerDeleted': '提供商已删除', + 'config.confirmDeleteProvider': '确定删除提供商 "{id}"?', + 'config.cannotDeleteActiveProvider': '无法删除当前正在使用的提供商,请先切换到其他提供商。', + 'config.providerFieldsRequired': '显示名称和提供商 ID 为必填项', + 'config.providerIdInvalid': '提供商 ID 只能包含小写字母、数字和连字符', + 'config.providerIdTaken': '提供商 ID "{id}" 已被占用', + // 配置 'config.title': '配置 {name}', 'config.telegramOwnerHint': '保存后,IronClaw 会显示一次性验证码。将 `/start CODE` 发送给你的 Telegram 机器人,IronClaw 会自动完成设置。', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 4e1074d0..f725a40c 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -97,6 +97,7 @@ +
@@ -317,6 +318,63 @@ + +
+
+
+
+

Model Providers

+ +
+ +
+
Loading...
+
+
+ +
+
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 06d9665a..8f64826d 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -4156,3 +4156,243 @@ mark { padding: 4px 8px; background: var(--bg-secondary); } + +/* --- Config Tab --- */ + +.config-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.config-section-header h3 { + margin-bottom: 0; +} + +.btn-add-provider { + padding: 5px 14px; + background: var(--accent); + color: #09090b; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background 0.2s, transform 0.2s; +} + +.btn-add-provider:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.config-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(245, 166, 35, 0.1); + border: 1px solid rgba(245, 166, 35, 0.3); + border-radius: var(--radius); + color: var(--warning); + font-size: 13px; + margin-bottom: 12px; +} + +.providers-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.provider-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + transition: border-color 0.2s; +} + +.provider-card:hover { + border-color: rgba(255, 255, 255, 0.15); +} + +.provider-card-active { + border-color: var(--accent); +} + +.provider-card-header { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.provider-name { + font-weight: 600; + font-size: 14px; + color: var(--text); +} + +.provider-id-label { + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); +} + +.provider-badge { + font-size: 10px; + padding: 2px 7px; + border-radius: 20px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.provider-badge-active { + background: rgba(52, 211, 153, 0.15); + color: var(--accent); +} + +.provider-badge-builtin { + background: rgba(161, 161, 170, 0.12); + color: var(--text-secondary); +} + +.provider-card-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.provider-adapter { + font-size: 12px; + color: var(--text-secondary); +} + +.provider-url { + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); + opacity: 0.7; +} + +.provider-card-actions { + display: flex; + gap: 6px; + margin-top: 2px; +} + +.provider-action-btn { + padding: 4px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + transition: color 0.2s, border-color 0.2s, background 0.2s; +} + +.provider-action-btn:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.2); + background: var(--bg); +} + +.provider-delete-btn:hover { + color: var(--danger); + border-color: var(--danger); +} + +/* Config form */ + +.config-form-section { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + background: var(--bg-secondary); +} + +.config-form { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 480px; +} + +.config-form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.config-form-row label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.config-form-row input, +.config-form-row select { + padding: 7px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; +} + +.config-form-row input:focus, +.config-form-row select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); +} + +.config-form-hint { + font-size: 11px; + color: var(--text-secondary); + opacity: 0.7; +} + +.config-form-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.config-form-actions button { + padding: 6px 18px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} + +.config-form-actions button:first-child { + background: var(--accent); + color: #09090b; + border: none; +} + +.config-form-actions button:first-child:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.config-form-actions .btn-secondary { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.config-form-actions .btn-secondary:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.2); +} diff --git a/src/config/llm.rs b/src/config/llm.rs index 64bf4ab8..9605eb27 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -57,14 +57,21 @@ impl LlmConfig { pub(crate) fn resolve(settings: &Settings) -> Result { let registry = ProviderRegistry::load(); - // Determine backend: env var > settings > default ("nearai") - let backend = if let Some(b) = optional_env("LLM_BACKEND")? { - b - } else if let Some(ref b) = settings.llm_backend { - b.clone() + // Determine backend: db settings > env var > default ("nearai") + let (backend, backend_source) = if let Some(ref b) = settings.llm_backend { + (b.clone(), "db:llm_backend") + } else if let Some(b) = optional_env("LLM_BACKEND")? { + (b, "env:LLM_BACKEND") } else { - "nearai".to_string() + ("nearai".to_string(), "default") }; + tracing::info!( + backend = %backend, + source = %backend_source, + db_llm_backend = ?settings.llm_backend, + custom_providers_count = settings.llm_custom_providers.len(), + "Resolving LLM backend" + ); // Validate the backend is known let backend_lower = backend.to_lowercase(); @@ -73,7 +80,13 @@ impl LlmConfig { let is_bedrock = backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws"; - if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() { + // Check custom providers defined + let custom_provider = settings + .llm_custom_providers + .iter() + .find(|p| p.id.to_lowercase() == backend_lower); + + if !is_nearai && !is_bedrock && custom_provider.is_none() && registry.find(&backend_lower).is_none() { tracing::warn!( "Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.", backend @@ -123,6 +136,8 @@ impl LlmConfig { // Resolve registry provider config (for non-NearAI, non-Bedrock backends) let provider = if is_nearai || is_bedrock { None + } else if let Some(custom) = custom_provider { + Some(Self::resolve_custom_provider(custom, settings)?) } else { Some(Self::resolve_registry_provider( &backend_lower, @@ -198,6 +213,58 @@ impl LlmConfig { }) } + /// Resolve a `RegistryProviderConfig` from a user-defined custom provider. + fn resolve_custom_provider( + custom: &crate::settings::CustomLlmProviderSettings, + settings: &Settings, + ) -> Result { + tracing::info!( + id = %custom.id, + adapter = %custom.adapter, + base_url = ?custom.base_url, + "Resolving custom LLM provider" + ); + let protocol = match custom.adapter.as_str() { + "anthropic" => ProviderProtocol::Anthropic, + "ollama" => ProviderProtocol::Ollama, + _ => ProviderProtocol::OpenAiCompletions, + }; + + let api_key = custom + .api_key + .as_ref() + .filter(|k| !k.is_empty()) + .map(|k| SecretString::from(k.clone())); + + let base_url = custom.base_url.clone().unwrap_or_default(); + if base_url.is_empty() { + tracing::warn!(id = %custom.id, "Custom provider has no base_url configured — requests will fail"); + } + + let model = optional_env("LLM_MODEL")? + .or_else(|| settings.selected_model.clone()) + .or_else(|| custom.default_model.clone()) + .unwrap_or_default(); + if model.is_empty() { + tracing::warn!(id = %custom.id, "Custom provider has no model configured — requests may fail"); + } + + Ok(RegistryProviderConfig { + protocol, + provider_id: custom.id.clone(), + api_key, + base_url, + model, + extra_headers: Vec::new(), + oauth_token: None, + is_codex_chatgpt: false, + refresh_token: None, + auth_path: None, + cache_retention: CacheRetention::default(), + unsupported_params: Vec::new(), + }) + } + /// Resolve a `RegistryProviderConfig` from the registry and env vars. fn resolve_registry_provider( backend: &str, @@ -1057,4 +1124,73 @@ mod tests { std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS"); } } + + // ── Custom provider tests ─────────────────────────────────────── + + #[test] + fn custom_provider_resolves_when_backend_matches_id() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("LLM_MODEL"); + } + + let settings = Settings { + llm_backend: Some("myprovider".to_string()), + llm_custom_providers: vec![crate::settings::CustomLlmProviderSettings { + id: "myprovider".to_string(), + name: "My Provider".to_string(), + adapter: "open_ai_completions".to_string(), + base_url: Some("https://api.example.com/v1".to_string()), + default_model: Some("my-model".to_string()), + api_key: Some("sk-test".to_string()), + builtin: false, + }], + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + assert_eq!(cfg.backend, "myprovider"); + let provider = cfg.provider.expect("provider config should be present"); + assert_eq!(provider.provider_id, "myprovider"); + assert_eq!(provider.base_url, "https://api.example.com/v1"); + assert_eq!(provider.model, "my-model"); + assert_eq!(provider.protocol, crate::llm::registry::ProviderProtocol::OpenAiCompletions); + } + + #[test] + fn db_llm_backend_takes_priority_over_env_var() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("LLM_BACKEND", "nearai"); + std::env::remove_var("LLM_MODEL"); + } + + let settings = Settings { + llm_backend: Some("myprovider".to_string()), + llm_custom_providers: vec![crate::settings::CustomLlmProviderSettings { + id: "myprovider".to_string(), + name: "My Provider".to_string(), + adapter: "open_ai_completions".to_string(), + base_url: Some("https://api.example.com/v1".to_string()), + default_model: Some("my-model".to_string()), + api_key: None, + builtin: false, + }], + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + assert_eq!( + cfg.backend, "myprovider", + "DB setting should override LLM_BACKEND env var" + ); + + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + } + } } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 3b6b01c4..a9dda339 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -78,6 +78,8 @@ pub async fn create_llm_provider( ) -> Result, LlmError> { let timeout = config.request_timeout_secs; + tracing::info!(backend = %config.backend, "Creating LLM provider"); + if config.backend == "nearai" || config.backend == "near_ai" || config.backend == "near" { return create_llm_provider_with_config(&config.nearai, session, timeout); } diff --git a/src/settings.rs b/src/settings.rs index 9a0b3942..6e355a8a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,6 +9,29 @@ use serde::{Deserialize, Serialize}; use crate::bootstrap::ironclaw_base_dir; +/// A custom LLM provider defined by the user through the web UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomLlmProviderSettings { + /// Unique identifier (used as `llm_backend` value). + pub id: String, + /// Display name. + pub name: String, + /// Adapter protocol: "open_ai_completions", "anthropic", "ollama". + pub adapter: String, + /// Base URL for the API endpoint. + #[serde(default)] + pub base_url: Option, + /// Default model identifier. + #[serde(default)] + pub default_model: Option, + /// Optional API key stored inline. + #[serde(default)] + pub api_key: Option, + /// Whether this is a built-in provider (should always be false for custom). + #[serde(default)] + pub builtin: bool, +} + /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -59,6 +82,10 @@ pub struct Settings { #[serde(default)] pub llm_backend: Option, + /// Custom LLM providers defined by the user through the web UI. + #[serde(default)] + pub llm_custom_providers: Vec, + /// Ollama base URL (when llm_backend = "ollama"). #[serde(default)] pub ollama_base_url: Option,