mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(web): fall back to env vars for LLM provider config in UI
This commit is contained in:
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user