diff --git a/src/channels/web/handlers/settings.rs b/src/channels/web/handlers/settings.rs index 8f2ae8cd..d076a9aa 100644 --- a/src/channels/web/handlers/settings.rs +++ b/src/channels/web/handlers/settings.rs @@ -72,6 +72,7 @@ pub async fn settings_set_handler( // Guard: cannot remove a custom provider that is currently active. if key == "llm_custom_providers" { guard_active_provider_not_removed(store, &state.user_id, &body.value).await?; + validate_custom_providers_adapters(&body.value)?; } store @@ -85,6 +86,25 @@ pub async fn settings_set_handler( Ok(StatusCode::NO_CONTENT) } +const VALID_ADAPTERS: &[&str] = &["open_ai_completions", "anthropic", "ollama"]; + +/// Returns `Err(422)` if any provider in the incoming list has an unrecognised adapter. +fn validate_custom_providers_adapters(value: &serde_json::Value) -> Result<(), StatusCode> { + let providers = match value.as_array() { + Some(arr) => arr, + None => return Ok(()), + }; + for p in providers { + if let Some(adapter) = p.get("adapter").and_then(|v| v.as_str()) + && !VALID_ADAPTERS.contains(&adapter) + { + tracing::warn!(adapter = %adapter, "Rejected unknown LLM adapter"); + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + } + Ok(()) +} + /// 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( @@ -147,6 +167,14 @@ pub async fn settings_delete_handler( .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + + // Guard: deleting llm_custom_providers is equivalent to setting it to []. + // Reject if the active backend is a custom provider that would be removed. + if key == "llm_custom_providers" { + guard_active_provider_not_removed(store, &state.user_id, &serde_json::Value::Array(vec![])) + .await?; + } + store .delete_setting(&state.user_id, &key) .await @@ -165,11 +193,35 @@ pub async fn settings_export_handler( .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { + let mut settings = store.get_all_settings(&state.user_id).await.map_err(|e| { tracing::error!("Failed to export settings: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; + // Redact API keys — never expose secrets over the export endpoint. + if let Some(val) = settings.get_mut("llm_custom_providers") + && let Some(providers) = val.as_array_mut() + { + for p in providers.iter_mut() { + if let Some(obj) = p.as_object_mut() + && obj.contains_key("api_key") + { + obj.insert("api_key".to_string(), serde_json::Value::Null); + } + } + } + if let Some(val) = settings.get_mut("llm_builtin_overrides") + && let Some(obj) = val.as_object_mut() + { + for override_val in obj.values_mut() { + if let Some(provider_obj) = override_val.as_object_mut() + && provider_obj.contains_key("api_key") + { + provider_obj.insert("api_key".to_string(), serde_json::Value::Null); + } + } + } + Ok(Json(SettingsExportResponse { settings })) } diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 1248533b..a966258d 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -51,6 +51,7 @@ use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::sse::SseManager; use crate::channels::web::types::*; use crate::channels::web::util::{build_turns_from_db_messages, truncate_preview}; +use crate::config::helpers::validate_base_url; use crate::db::Database; use crate::extensions::ExtensionManager; use crate::orchestrator::job_manager::ContainerJobManager; @@ -2524,6 +2525,13 @@ async fn llm_test_connection_handler( } async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionResponse { + if let Err(e) = validate_base_url(&req.base_url, "base_url") { + return TestConnectionResponse { + ok: false, + message: format!("Invalid base URL: {e}"), + }; + } + let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() @@ -2662,6 +2670,14 @@ async fn llm_list_models_handler(Json(body): Json) -> Json
  • ListModelsResponse { + if let Err(e) = validate_base_url(&req.base_url, "base_url") { + return ListModelsResponse { + ok: false, + models: vec![], + message: format!("Invalid base URL: {e}"), + }; + } + let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 0c8553da..64b9edd1 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -6084,6 +6084,9 @@ document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') { closeConfirmModal(); } + if (e.key === 'Escape' && document.getElementById('provider-dialog').style.display === 'flex') { + resetProviderForm(); + } }); // --- Settings Import/Export --- @@ -6195,8 +6198,8 @@ function apiFetchVoid(path, options) { // 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://private-chat-stg.near.ai/v1', builtin: true, default_model: 'zai-org/GLM-5-FP8', api_key_required: true, can_list_models: true }, - { 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: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://cloud-api.near.ai/v1', builtin: true, default_model: 'zai-org/GLM-5-FP8', api_key_required: true, can_list_models: true }, + { id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true, default_model: 'gpt-4o-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 }, @@ -6296,37 +6299,37 @@ function renderProviders() { ? '' + I18n.t('config.builtin') + '' : ''; const deleteBtn = !p.builtin && !isActive - ? '' + ? '' : ''; const editBtn = !p.builtin - ? '' + ? '' : ''; // Show Configure for built-in providers that support it (not bedrock — uses AWS credential chain) const configureBtn = p.builtin && p.id !== 'bedrock' - ? '' + ? '' : ''; const useBtn = !isActive - ? '' + ? '' : ''; const baseUrlText = p.base_url - ? '' + escHtml(p.base_url) + '' + ? '' + escapeHtml(p.base_url) + '' : ''; // 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 })) + '' + ? '' + escapeHtml(I18n.t('config.currentModel', { model: displayModel })) + '' : ''; return '
    ' + '
    ' - + '' + escHtml(p.name || p.id) + '' - + '' + escHtml(p.id) + '' + + '' + escapeHtml(p.name || p.id) + '' + + '' + escapeHtml(p.id) + '' + activeBadge + builtinBadge + '
    ' + '
    ' - + '' + escHtml(adapterLabel) + '' + + '' + escapeHtml(adapterLabel) + '' + baseUrlText + modelText + '
    ' @@ -6337,10 +6340,6 @@ function renderProviders() { }).join(''); } -function escHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - function setActiveProvider(id) { const provider = [...BUILTIN_PROVIDERS, ..._customProviders].find((p) => p.id === id); // Restore the last-configured model for this provider, falling back to the provider's default @@ -6678,7 +6677,7 @@ document.getElementById('fetch-models-btn').addEventListener('click', () => { if (data.ok && data.models && data.models.length > 0) { const currentModel = document.getElementById('provider-model').value; select.innerHTML = data.models - .map((m) => ``) + .map((m) => ``) .join(''); select.style.display = ''; btn.style.display = 'none'; diff --git a/src/config/llm.rs b/src/config/llm.rs index 3ff71f7e..125907ce 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -75,6 +75,17 @@ impl LlmConfig { custom_providers_count = settings.llm_custom_providers.len(), "Resolving LLM backend" ); + // Warn operators when a DB-persisted value silently overrides LLM_BACKEND. + if backend_source == "db:llm_backend" + && let Ok(Some(env_val)) = optional_env("LLM_BACKEND") + { + tracing::warn!( + db_value = %backend, + env_value = %env_val, + "LLM_BACKEND env var is set but DB setting takes priority. \ + Unset llm_backend in the DB (via settings UI) to use the env var." + ); + } // Validate the backend is known let backend_lower = backend.to_lowercase(); @@ -1353,8 +1364,16 @@ mod tests { #[test] fn db_llm_backend_takes_priority_over_env_var() { - let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); - // SAFETY: Under ENV_MUTEX. + let _guard = lock_env(); + // SAFETY: Under ENV_MUTEX. RAII guard removes LLM_BACKEND on drop so + // a panicking assertion cannot leak the env var to other tests. + struct RemoveOnDrop(&'static str); + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + unsafe { std::env::remove_var(self.0) }; + } + } + let _cleanup = RemoveOnDrop("LLM_BACKEND"); unsafe { std::env::set_var("LLM_BACKEND", "nearai"); std::env::remove_var("LLM_MODEL"); @@ -1379,10 +1398,6 @@ mod tests { cfg.backend, "myprovider", "DB setting should override LLM_BACKEND env var" ); - // SAFETY: Under ENV_MUTEX. - unsafe { - std::env::remove_var("LLM_BACKEND"); - } } // ── OpenAI Codex tests ──────────────────────────────────────────