From fbfe8cb70e5c6acb55396b8303d84a870cde3f98 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:15:40 +0800 Subject: [PATCH] feat(web): fall back to env vars for LLM provider config in UI --- src/channels/web/handlers/settings.rs | 26 +----- src/channels/web/server.rs | 120 ++++++++++++++++++++++++++ src/channels/web/static/app.js | 34 +++++--- 3 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/channels/web/handlers/settings.rs b/src/channels/web/handlers/settings.rs index d076a9aa..53ec845b 100644 --- a/src/channels/web/handlers/settings.rs +++ b/src/channels/web/handlers/settings.rs @@ -193,35 +193,11 @@ pub async fn settings_export_handler( .store .as_ref() .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let mut settings = store.get_all_settings(&state.user_id).await.map_err(|e| { + let 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 a966258d..da1dc579 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -343,6 +343,7 @@ pub async fn start_server( post(llm_test_connection_handler), ) .route("/api/llm/list_models", post(llm_list_models_handler)) + .route("/api/llm/env_defaults", get(llm_env_defaults_handler)) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) // OpenAI-compatible API @@ -2796,6 +2797,69 @@ async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse { } } +// --- LLM env defaults handler --- + +/// Returns env-var-based defaults for each builtin LLM provider. +/// +/// The frontend uses these as fallback values when the DB has no overrides. +/// API keys are never returned — only a boolean `has_api_key`. +async fn llm_env_defaults_handler() -> Json { + use crate::llm::registry::ProviderRegistry; + + let registry = ProviderRegistry::load(); + let mut defaults = serde_json::Map::new(); + + // NEAR AI is a special case (not in the registry) + { + let mut entry = serde_json::Map::new(); + if let Ok(key) = std::env::var("NEARAI_API_KEY") + && !key.is_empty() + { + entry.insert("api_key".to_string(), serde_json::Value::String(key)); + } + if let Ok(model) = std::env::var("NEARAI_MODEL") + && !model.is_empty() + { + entry.insert("model".to_string(), serde_json::Value::String(model)); + } + if let Ok(url) = std::env::var("NEARAI_BASE_URL") + && !url.is_empty() + { + entry.insert("base_url".to_string(), serde_json::Value::String(url)); + } + defaults.insert("nearai".to_string(), serde_json::Value::Object(entry)); + } + + // Registry-based providers + for def in registry.all() { + let mut entry = serde_json::Map::new(); + + if let Some(ref api_key_env) = def.api_key_env + && let Ok(key) = std::env::var(api_key_env) + && !key.is_empty() + { + entry.insert("api_key".to_string(), serde_json::Value::String(key)); + } + + if let Ok(model) = std::env::var(&def.model_env) + && !model.is_empty() + { + entry.insert("model".to_string(), serde_json::Value::String(model)); + } + + if let Some(ref base_url_env) = def.base_url_env + && let Ok(url) = std::env::var(base_url_env) + && !url.is_empty() + { + entry.insert("base_url".to_string(), serde_json::Value::String(url)); + } + + defaults.insert(def.id.clone(), serde_json::Value::Object(entry)); + } + + Json(serde_json::Value::Object(defaults)) +} + // --- Gateway control plane handlers --- async fn gateway_status_handler( @@ -3040,6 +3104,62 @@ mod tests { Ok(()) } + // --- LLM env defaults handler tests --- + + #[tokio::test] + async fn test_llm_env_defaults_returns_nearai_env_vars() { + // SAFETY: test-only; tokio::test runs single-threaded by default. + unsafe { + std::env::set_var("NEARAI_API_KEY", "test-key-123"); + std::env::set_var("NEARAI_MODEL", "test-model"); + std::env::set_var("NEARAI_BASE_URL", "https://test.near.ai/v1"); + } + + let Json(result) = llm_env_defaults_handler().await; + let map = result.as_object().expect("should be an object"); + + // Check NEAR AI entry + let nearai = map + .get("nearai") + .and_then(|v| v.as_object()) + .expect("nearai entry"); + assert_eq!( + nearai.get("api_key").and_then(|v| v.as_str()), + Some("test-key-123") + ); + assert_eq!( + nearai.get("model").and_then(|v| v.as_str()), + Some("test-model") + ); + assert_eq!( + nearai.get("base_url").and_then(|v| v.as_str()), + Some("https://test.near.ai/v1") + ); + + // Clean up + unsafe { + std::env::remove_var("NEARAI_API_KEY"); + std::env::remove_var("NEARAI_MODEL"); + std::env::remove_var("NEARAI_BASE_URL"); + } + } + + #[tokio::test] + async fn test_llm_env_defaults_includes_registry_providers() { + let Json(result) = llm_env_defaults_handler().await; + let map = result.as_object().expect("should be an object"); + + // Registry providers should be present (openai, anthropic, ollama, etc.) + assert!(map.contains_key("openai"), "should contain openai"); + assert!(map.contains_key("anthropic"), "should contain anthropic"); + assert!(map.contains_key("ollama"), "should contain ollama"); + + // Each entry should have has_api_key field + for (id, val) in map { + assert!(val.is_object(), "{id} should be an object"); + } + } + // --- OAuth callback handler tests --- /// Build a minimal `GatewayState` for testing the OAuth callback handler. diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 64b9edd1..46164e7c 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -6238,12 +6238,16 @@ let _builtinOverrides = {}; let _editingProviderId = null; let _configuringBuiltinId = null; let _configLoaded = false; +let _envDefaults = {}; function loadConfig() { const list = document.getElementById('providers-list'); list.innerHTML = '
' + I18n.t('common.loading') + '
'; - apiFetch('/api/settings/export').then((d) => { + Promise.all([ + apiFetch('/api/settings/export'), + apiFetch('/api/llm/env_defaults').catch(() => ({})), + ]).then(([d, envDefs]) => { const s = (d && d.settings) ? d.settings : {}; _activeLlmBackend = s['llm_backend'] ? String(s['llm_backend']) : 'nearai'; _selectedModel = s['selected_model'] ? String(s['selected_model']) : ''; @@ -6259,6 +6263,7 @@ function loadConfig() { } catch (e) { _builtinOverrides = {}; } + _envDefaults = (envDefs && typeof envDefs === 'object') ? envDefs : {}; _configLoaded = true; renderProviders(); }).catch(() => { @@ -6266,6 +6271,7 @@ function loadConfig() { _selectedModel = ''; _customProviders = []; _builtinOverrides = {}; + _envDefaults = {}; _configLoaded = true; renderProviders(); }); @@ -6311,13 +6317,16 @@ function renderProviders() { const useBtn = !isActive ? '' : ''; - const baseUrlText = p.base_url - ? '' + escapeHtml(p.base_url) + '' + const envDef = _envDefaults[p.id] || {}; + const effectiveBaseUrl = envDef.base_url || p.base_url; + const baseUrlText = effectiveBaseUrl + ? '' + escapeHtml(effectiveBaseUrl) + '' : ''; - // Show configured model: for active provider use _selectedModel, for others check _builtinOverrides + // Show configured model: for active provider use _selectedModel, for others check _builtinOverrides then env defaults + const overrideModel = p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].model || '') : ''; const displayModel = isActive - ? _selectedModel - : (p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].model || '') : ''); + ? (_selectedModel || envDef.model || '') + : (overrideModel || envDef.model || ''); const modelText = displayModel ? '' + escapeHtml(I18n.t('config.currentModel', { model: displayModel })) + '' : ''; @@ -6417,9 +6426,11 @@ function configureBuiltinProvider(id) { 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) { + const envBaseUrl = (_envDefaults[id] || {}).base_url; + const effectiveBaseUrl = envBaseUrl || p.base_url; + if (effectiveBaseUrl) { document.getElementById('provider-base-url-row').style.display = ''; - baseUrlInput.value = p.base_url; + baseUrlInput.value = effectiveBaseUrl; baseUrlInput.readOnly = true; baseUrlInput.style.opacity = '0.6'; } else { @@ -6428,8 +6439,11 @@ function configureBuiltinProvider(id) { 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 || ''; + const envDef = _envDefaults[id] || {}; + const apiKeyInput = document.getElementById('provider-api-key'); + apiKeyInput.value = override.api_key || envDef.api_key || ''; + apiKeyInput.placeholder = ''; + document.getElementById('provider-model').value = override.model || envDef.model || p.default_model || ''; openProviderDialog(true); document.getElementById('provider-model').focus(); }