From 275a6b176197cc66e216335ad06b5dd7134c89e1 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:23:48 +0800 Subject: [PATCH] feat: add built-in provider API key and model configuration - Add Configure button on built-in provider cards (openai, anthropic, gemini, ollama, etc.) to set API key and default model via web UI - Store overrides as `llm_builtin_overrides` setting (per-provider key/model map) using the existing generic settings k/v API - Add LlmBuiltinOverride struct in settings.rs; resolve in resolve_registry_provider() with priority: env var > selected_model > llm_builtin_overrides[id] > default - Restore provider's configured model to selected_model on provider switch, so /model command always takes precedence at runtime - Fix fetch-models button in built-in configure mode: use hardcoded base_url from BUILTIN_PROVIDERS instead of the hidden form field - Add edit support for custom providers with pre-filled dialog - Show current model on active and configured provider cards - Convert add/edit provider form to a modal dialog - Sync selected_model when editing or deleting an active custom provider --- src/channels/web/static/app.js | 186 +++++++++++++++++++++----- src/channels/web/static/i18n/en.js | 2 + src/channels/web/static/i18n/zh-CN.js | 2 + src/channels/web/static/index.html | 10 +- src/config/llm.rs | 113 +++++++++++++++- src/llm/rig_adapter.rs | 64 +++++++++ src/settings.rs | 19 +++ 7 files changed, 356 insertions(+), 40 deletions(-) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index a604a5d5..3d47810b 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -4689,6 +4689,9 @@ document.addEventListener('click', function(e) { case 'edit-custom-provider': editCustomProvider(el.dataset.id); break; + case 'configure-builtin-provider': + configureBuiltinProvider(el.dataset.id); + break; } }); @@ -4715,30 +4718,32 @@ function apiFetchVoid(path, options) { } // Generated from providers.json + nearai/bedrock (handled separately in llm.rs) +// Fields: id, name, adapter, base_url, builtin, default_model, api_key_required, can_list_models +// nearai/bedrock use special auth flows — no Configure button (api_key_required=false, can_list_models=false) 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 }, + { id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://api.near.ai/v1', builtin: true, default_model: '', api_key_required: false, can_list_models: false }, + { id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true, default_model: 'gpt-5-mini', api_key_required: true, can_list_models: true }, + { id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true, default_model: 'claude-sonnet-4-20250514', api_key_required: true, can_list_models: true }, + { id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true, default_model: 'llama3', api_key_required: false, can_list_models: true }, + { id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: 'default', api_key_required: false, can_list_models: false }, + { id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true, default_model: 'gemini-2.5-flash', api_key_required: true, can_list_models: true }, + { id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true, default_model: 'llama-3.3-70b-versatile', api_key_required: true, can_list_models: true }, + { id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true, default_model: 'openai/gpt-4o', api_key_required: true, can_list_models: false }, + { id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true, default_model: 'deepseek-chat', api_key_required: true, can_list_models: false }, + { id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true, default_model: 'mistral-large-latest', api_key_required: true, can_list_models: true }, + { id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true, default_model: 'kimi-k2-5', api_key_required: true, can_list_models: false }, + { id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true, default_model: 'meta/llama-3.3-70b-instruct', api_key_required: true, can_list_models: true }, + { id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true, default_model: 'meta-llama/Llama-3-70b-chat-hf', api_key_required: true, can_list_models: false }, + { id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true, default_model: 'accounts/fireworks/models/llama-v3p1-70b-instruct', api_key_required: true, can_list_models: false }, + { id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false }, + { id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true, default_model: 'Meta-Llama-3.1-70B-Instruct', api_key_required: true, can_list_models: false }, + { id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true, default_model: 'glm-5', api_key_required: true, can_list_models: false }, + { id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false }, + { id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true, default_model: 'MiniMax-M2.5', api_key_required: true, can_list_models: false }, + { id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true, default_model: 'deepseek-coder-v2-instruct', api_key_required: true, can_list_models: true }, + { id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', api_key_required: true, can_list_models: false }, + { id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true, default_model: 'yandexgpt-lite', api_key_required: true, can_list_models: true }, + { id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true, default_model: '', api_key_required: false, can_list_models: false }, ]; const ADAPTER_LABELS = { @@ -4752,7 +4757,9 @@ const ADAPTER_LABELS = { let _customProviders = []; let _activeLlmBackend = ''; let _selectedModel = ''; +let _builtinOverrides = {}; let _editingProviderId = null; +let _configuringBuiltinId = null; let _configLoaded = false; function loadConfig() { @@ -4769,12 +4776,19 @@ function loadConfig() { } catch (e) { _customProviders = []; } + try { + const val = s['llm_builtin_overrides']; + _builtinOverrides = (val && typeof val === 'object' && !Array.isArray(val)) ? val : {}; + } catch (e) { + _builtinOverrides = {}; + } _configLoaded = true; renderProviders(); }).catch(() => { _activeLlmBackend = 'nearai'; _selectedModel = ''; _customProviders = []; + _builtinOverrides = {}; _configLoaded = true; renderProviders(); }); @@ -4808,14 +4822,22 @@ function renderProviders() { const editBtn = !p.builtin ? '' : ''; + // Show Configure for built-in providers that support it (not nearai/bedrock) + const configureBtn = p.builtin && p.id !== 'nearai' && p.id !== 'bedrock' + ? '' + : ''; const useBtn = !isActive ? '' : ''; const baseUrlText = p.base_url ? '' + escHtml(p.base_url) + '' : ''; - const modelText = isActive && _selectedModel - ? '' + escHtml(I18n.t('config.currentModel', { model: _selectedModel })) + '' + // Show configured model: for active provider use _selectedModel, for others check _builtinOverrides + const displayModel = isActive + ? _selectedModel + : (p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].model || '') : ''); + const modelText = displayModel + ? '' + escHtml(I18n.t('config.currentModel', { model: displayModel })) + '' : ''; return '
' @@ -4830,7 +4852,7 @@ function renderProviders() { + modelText + '
' + '
' - + useBtn + editBtn + deleteBtn + + useBtn + configureBtn + editBtn + deleteBtn + '
' + ''; }).join(''); @@ -4842,7 +4864,12 @@ function escHtml(s) { function setActiveProvider(id) { const provider = [...BUILTIN_PROVIDERS, ..._customProviders].find((p) => p.id === id); - const defaultModel = provider && provider.default_model ? provider.default_model : null; + // Restore the last-configured model for this provider, falling back to the provider's default + const restoredModel = + (_builtinOverrides[id] && _builtinOverrides[id].model) || + (provider && provider.default_model) || + null; + const defaultModel = restoredModel; const modelUpdate = defaultModel ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } }) : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }); @@ -4896,6 +4923,35 @@ function editCustomProvider(id) { document.getElementById('provider-name').focus(); } +function configureBuiltinProvider(id) { + const p = BUILTIN_PROVIDERS.find((p) => p.id === id); + if (!p) return; + _configuringBuiltinId = id; + const titleEl = document.getElementById('provider-form-title'); + titleEl.textContent = I18n.t('config.configureProvider') + ': ' + escHtml(p.name || id); + titleEl.removeAttribute('data-i18n'); + // Hide name/id/adapter rows; show base-url as read-only for reference + document.getElementById('provider-name-row').style.display = 'none'; + document.getElementById('provider-id-row').style.display = 'none'; + document.getElementById('provider-adapter-row').style.display = 'none'; + const baseUrlInput = document.getElementById('provider-base-url'); + if (p.base_url) { + document.getElementById('provider-base-url-row').style.display = ''; + baseUrlInput.value = p.base_url; + baseUrlInput.readOnly = true; + baseUrlInput.style.opacity = '0.6'; + } else { + document.getElementById('provider-base-url-row').style.display = 'none'; + } + document.getElementById('provider-api-key-row').style.display = p.api_key_required !== false ? '' : 'none'; + document.getElementById('fetch-models-btn').style.display = p.can_list_models ? '' : 'none'; + const override = _builtinOverrides[id] || {}; + document.getElementById('provider-api-key').value = override.api_key || ''; + document.getElementById('provider-model').value = override.model || p.default_model || ''; + openProviderDialog(true); + document.getElementById('provider-model').focus(); +} + // Add provider form document.getElementById('add-provider-btn').addEventListener('click', () => { @@ -4915,6 +4971,14 @@ document.getElementById('provider-dialog-overlay').addEventListener('click', () }); function openProviderDialog(isEdit) { + if (!isEdit) { + // Add mode: ensure all rows visible + ['provider-name-row', 'provider-id-row', 'provider-adapter-row', + 'provider-base-url-row', 'provider-api-key-row'].forEach((id) => { + document.getElementById(id).style.display = ''; + }); + document.getElementById('fetch-models-btn').style.display = ''; + } document.getElementById('provider-dialog').style.display = 'flex'; if (!isEdit) { document.getElementById('provider-name').focus(); @@ -4922,11 +4986,20 @@ function openProviderDialog(isEdit) { } document.getElementById('test-provider-btn').addEventListener('click', () => { - const adapter = document.getElementById('provider-adapter').value; - const baseUrl = document.getElementById('provider-base-url').value.trim(); + let adapter = document.getElementById('provider-adapter').value; + let 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(); + // For built-in providers, use the hardcoded adapter/base_url from BUILTIN_PROVIDERS + if (_configuringBuiltinId) { + const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId); + if (p) { + adapter = p.adapter; + baseUrl = p.base_url; + } + } + const btn = document.getElementById('test-provider-btn'); const result = document.getElementById('test-connection-result'); @@ -4956,6 +5029,38 @@ document.getElementById('test-provider-btn').addEventListener('click', () => { }); document.getElementById('save-provider-btn').addEventListener('click', () => { + // Built-in configure mode: save api_key + model to llm_builtin_overrides + if (_configuringBuiltinId) { + const apiKey = document.getElementById('provider-api-key').value.trim(); + const model = document.getElementById('provider-model').value.trim(); + const id = _configuringBuiltinId; + const override = {}; + if (apiKey) override.api_key = apiKey; + if (model) override.model = model; + const prev = _builtinOverrides[id]; + _builtinOverrides[id] = override; + const isActive = id === _activeLlmBackend; + const modelUpdate = isActive + ? (model + ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } }) + : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' })) + : Promise.resolve(); + apiFetchVoid('/api/settings/llm_builtin_overrides', { method: 'PUT', body: { value: _builtinOverrides } }) + .then(() => modelUpdate) + .then(() => { + if (isActive) _selectedModel = model; + renderProviders(); + resetProviderForm(); + document.getElementById('config-restart-notice').style.display = 'flex'; + showToast(I18n.t('config.providerConfigured', { name: id })); + }) + .catch((e) => { + if (prev !== undefined) { _builtinOverrides[id] = prev; } else { delete _builtinOverrides[id]; } + showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'); + }); + return; + } + const name = document.getElementById('provider-name').value.trim(); const id = document.getElementById('provider-id').value.trim(); const adapter = document.getElementById('provider-adapter').value; @@ -5019,13 +5124,23 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { function resetProviderForm() { _editingProviderId = null; + _configuringBuiltinId = null; document.getElementById('provider-dialog').style.display = 'none'; + // Restore all hidden rows and buttons + ['provider-name-row', 'provider-id-row', 'provider-adapter-row', + 'provider-base-url-row', 'provider-api-key-row'].forEach((id) => { + document.getElementById(id).style.display = ''; + }); + document.getElementById('fetch-models-btn').style.display = ''; const titleEl = document.getElementById('provider-form-title'); titleEl.setAttribute('data-i18n', 'config.newProvider'); titleEl.textContent = I18n.t('config.newProvider'); const idField = document.getElementById('provider-id'); idField.readOnly = false; idField.style.opacity = ''; + const baseUrlField = document.getElementById('provider-base-url'); + baseUrlField.readOnly = false; + baseUrlField.style.opacity = ''; ['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => { document.getElementById(id).value = ''; }); @@ -5041,10 +5156,19 @@ document.getElementById('provider-model-select').addEventListener('change', (e) }); document.getElementById('fetch-models-btn').addEventListener('click', () => { - const adapter = document.getElementById('provider-adapter').value; - const baseUrl = document.getElementById('provider-base-url').value.trim(); + let adapter = document.getElementById('provider-adapter').value; + let baseUrl = document.getElementById('provider-base-url').value.trim(); const apiKey = document.getElementById('provider-api-key').value.trim(); + // For built-in providers, use the hardcoded base_url and adapter from BUILTIN_PROVIDERS + if (_configuringBuiltinId) { + const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId); + if (p) { + adapter = p.adapter; + baseUrl = p.base_url; + } + } + if (!baseUrl) { showToast(I18n.t('config.providerBaseUrlRequired'), 'error'); return; diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 971786f5..25163396 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -348,6 +348,8 @@ I18n.register('en', { 'config.restartNotice': 'Changes take effect after restart.', 'config.builtin': 'built-in', 'config.useProvider': 'Use', + 'config.configureProvider': 'Configure', + 'config.providerConfigured': 'Provider "{name}" configured (restart to apply)', 'config.currentModel': 'Model: {model}', 'config.providerName': 'Display Name', 'config.providerNamePlaceholder': 'My Provider', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index e5a406b1..0d814b2c 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -348,6 +348,8 @@ I18n.register('zh-CN', { 'config.restartNotice': '更改将在重启后生效。', 'config.builtin': '内置', 'config.useProvider': '使用', + 'config.configureProvider': '配置', + 'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)', 'config.currentModel': '模型:{model}', 'config.providerName': '显示名称', 'config.providerNamePlaceholder': '我的提供商', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index dace6e4a..c354dd41 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -54,16 +54,16 @@
-
+
-
+
Lowercase letters, numbers, hyphens
-
+
-
+
-
+
diff --git a/src/config/llm.rs b/src/config/llm.rs index 9605eb27..bd56bec8 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -342,8 +342,14 @@ impl LlmConfig { } Some(creds.token) } else if let Some(env_var) = api_key_env { - // Resolve API key from env (including secrets store overlay) - optional_env(env_var)?.map(SecretString::from) + // Resolve API key: env var (including secrets store overlay) > settings override + optional_env(env_var)?.map(SecretString::from).or_else(|| { + settings + .llm_builtin_overrides + .get(backend) + .and_then(|o| o.api_key.as_ref()) + .map(|k| SecretString::from(k.clone())) + }) } else { None }; @@ -392,8 +398,16 @@ impl LlmConfig { }); } - // Resolve model - let model = Self::resolve_model(model_env, settings, default_model)?; + // Resolve model: env var > selected_model (/model command) > per-provider override > registry default + let model = optional_env(model_env)? + .or_else(|| settings.selected_model.clone()) + .or_else(|| { + settings + .llm_builtin_overrides + .get(backend) + .and_then(|o| o.model.clone()) + }) + .unwrap_or_else(|| default_model.to_string()); // Resolve extra headers let extra_headers = if let Some(env_var) = extra_headers_env { @@ -1193,4 +1207,95 @@ mod tests { std::env::remove_var("LLM_BACKEND"); } } + + #[test] + fn builtin_override_model_used_when_no_selected_model() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("GROQ_MODEL"); + } + + let mut overrides = std::collections::HashMap::new(); + overrides.insert( + "groq".to_string(), + crate::settings::LlmBuiltinOverride { api_key: None, model: Some("llama-3.1-8b-instant".to_string()) }, + ); + let settings = Settings { + llm_backend: Some("groq".to_string()), + llm_builtin_overrides: overrides, + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + assert_eq!( + provider.model, "llama-3.1-8b-instant", + "builtin override model should be used when selected_model is unset" + ); + } + + #[test] + fn selected_model_takes_priority_over_builtin_override_model() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("GROQ_MODEL"); + } + + let mut overrides = std::collections::HashMap::new(); + overrides.insert( + "groq".to_string(), + crate::settings::LlmBuiltinOverride { api_key: None, model: Some("llama-3.1-8b-instant".to_string()) }, + ); + let settings = Settings { + llm_backend: Some("groq".to_string()), + selected_model: Some("llama-3.3-70b-versatile".to_string()), + llm_builtin_overrides: overrides, + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + assert_eq!( + provider.model, "llama-3.3-70b-versatile", + "selected_model (/model command) must take priority over builtin override" + ); + } + + #[test] + fn builtin_override_api_key_used_when_no_env_var() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("GROQ_API_KEY"); + std::env::remove_var("GROQ_MODEL"); + } + + let mut overrides = std::collections::HashMap::new(); + overrides.insert( + "groq".to_string(), + crate::settings::LlmBuiltinOverride { + api_key: Some("gsk_test_key".to_string()), + model: Some("llama-3.3-70b-versatile".to_string()), + }, + ); + let settings = Settings { + llm_backend: Some("groq".to_string()), + llm_builtin_overrides: overrides, + ..Default::default() + }; + + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + use secrecy::ExposeSecret as _; + let key = provider.api_key.expect("api_key should be set from builtin override"); + assert_eq!( + key.expose_secret(), "gsk_test_key", + "builtin override api_key should be used when env var is absent" + ); + } } diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 5ca4fec0..7b336def 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -1370,4 +1370,68 @@ mod tests { // Should be 2 separate User messages (text user + tool result user) assert_eq!(history.len(), 2); } + + /// Empty user messages (e.g. after thinking-tag stripping) must be skipped. + /// Strict providers like Kimi return 400 when "content": "" is sent. + #[test] + fn test_empty_user_message_is_skipped() { + let empty = ChatMessage::user(""); + let non_empty = ChatMessage::user("hello"); + let messages = vec![empty, non_empty]; + let (_preamble, history) = convert_messages(&messages); + + assert_eq!(history.len(), 1, "empty user message must be dropped"); + match &history[0] { + RigMessage::User { content } => { + assert_eq!(content.len(), 1); + let first = content.iter().next().expect("one content item"); + match first { + UserContent::Text(t) => assert_eq!(t.text, "hello"), + other => panic!("expected Text, got {:?}", other), + } + } + other => panic!("expected User message, got {:?}", other), + } + } + + /// Empty assistant messages (e.g. after thinking-tag stripping) must be skipped. + #[test] + fn test_empty_assistant_message_is_skipped() { + let empty_asst = ChatMessage { + role: crate::llm::Role::Assistant, + content: String::new(), + tool_calls: None, + tool_call_id: None, + name: None, + content_parts: vec![], + }; + let non_empty = ChatMessage::user("hi"); + let messages = vec![empty_asst, non_empty]; + let (_preamble, history) = convert_messages(&messages); + + assert_eq!(history.len(), 1, "empty assistant message must be dropped"); + assert!(matches!(history[0], RigMessage::User { .. })); + } + + /// A conversation mixing normal and empty messages: only non-empty ones survive. + #[test] + fn test_mixed_empty_and_non_empty_messages_filtered_correctly() { + let user1 = ChatMessage::user("first"); + let empty_asst = ChatMessage { + role: crate::llm::Role::Assistant, + content: String::new(), + tool_calls: None, + tool_call_id: None, + name: None, + content_parts: vec![], + }; + let user2 = ChatMessage::user(""); + let asst = ChatMessage::assistant("response"); + let messages = vec![user1, empty_asst, user2, asst]; + let (_preamble, history) = convert_messages(&messages); + + assert_eq!(history.len(), 2, "only non-empty messages should survive"); + assert!(matches!(history[0], RigMessage::User { .. })); + assert!(matches!(history[1], RigMessage::Assistant { .. })); + } } diff --git a/src/settings.rs b/src/settings.rs index 6e355a8a..1750f697 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -3,6 +3,7 @@ //! Stores user preferences in ~/.ironclaw/settings.json. //! Settings are loaded with env var > settings.json > default priority. +use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; @@ -32,6 +33,20 @@ pub struct CustomLlmProviderSettings { pub builtin: bool, } +/// Per-provider overrides for built-in LLM providers (API key and/or model). +/// +/// Stored as `llm_builtin_overrides` in the settings store, keyed by provider ID +/// (e.g. `"openai"`, `"gemini"`). Resolved at startup during `LlmConfig::resolve()`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LlmBuiltinOverride { + /// API key override (takes precedence over env var). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Default model override (takes precedence over global `selected_model`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -86,6 +101,10 @@ pub struct Settings { #[serde(default)] pub llm_custom_providers: Vec, + /// Per-provider overrides for built-in providers (API key and/or model). + #[serde(default)] + pub llm_builtin_overrides: HashMap, + /// Ollama base URL (when llm_backend = "ollama"). #[serde(default)] pub ollama_base_url: Option,