feat(web): fall back to env vars for LLM provider config in UI

This commit is contained in:
italic-jinxin
2026-03-24 18:15:40 +08:00
parent fc9590dfd6
commit fbfe8cb70e
3 changed files with 145 additions and 35 deletions
+1 -25
View File
@@ -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 }))
}
+120
View File
@@ -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<serde_json::Value> {
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.
+24 -10
View File
@@ -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 = '<div class="empty-state">' + I18n.t('common.loading') + '</div>';
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
? '<button class="provider-action-btn" data-action="set-active-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('config.useProvider') + '</button>'
: '';
const baseUrlText = p.base_url
? '<span class="provider-url">' + escapeHtml(p.base_url) + '</span>'
const envDef = _envDefaults[p.id] || {};
const effectiveBaseUrl = envDef.base_url || p.base_url;
const baseUrlText = effectiveBaseUrl
? '<span class="provider-url">' + escapeHtml(effectiveBaseUrl) + '</span>'
: '';
// 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
? '<span class="provider-current-model">' + escapeHtml(I18n.t('config.currentModel', { model: displayModel })) + '</span>'
: '';
@@ -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();
}