diff --git a/src/channels/web/handlers/settings.rs b/src/channels/web/handlers/settings.rs index a7749daa..8f2ae8cd 100644 --- a/src/channels/web/handlers/settings.rs +++ b/src/channels/web/handlers/settings.rs @@ -71,9 +71,7 @@ pub async fn settings_set_handler( // Guard: cannot remove a custom provider that is currently active. if key == "llm_custom_providers" { - if let Err(status) = guard_active_provider_not_removed(store, &state.user_id, &body.value).await { - return Err(status); - } + guard_active_provider_not_removed(store, &state.user_id, &body.value).await?; } store @@ -119,17 +117,17 @@ async fn guard_active_provider_not_removed( None => return Ok(()), }; - let active_was_custom = old_providers.iter().any(|p| { - p.get("id").and_then(|v| v.as_str()) == Some(&active_backend) - }); + let active_was_custom = old_providers + .iter() + .any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend)); if !active_was_custom { return Ok(()); } // Reject if the active provider is absent from the new list. - let still_present = new_providers.iter().any(|p| { - p.get("id").and_then(|v| v.as_str()) == Some(&active_backend) - }); + let still_present = new_providers + .iter() + .any(|p| p.get("id").and_then(|v| v.as_str()) == Some(&active_backend)); if !still_present { tracing::warn!( active_backend = %active_backend, diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index f0bd4c9a..699a5571 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -36,6 +36,10 @@ use crate::channels::web::handlers::jobs::{ jobs_summary_handler, }; use crate::channels::web::handlers::routines::{routines_delete_handler, routines_toggle_handler}; +use crate::channels::web::handlers::settings::{ + settings_delete_handler, settings_export_handler, settings_get_handler, + settings_import_handler, settings_list_handler, settings_set_handler, +}; use crate::channels::web::handlers::skills::{ skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler, }; @@ -312,7 +316,10 @@ pub async fn start_server( axum::routing::delete(settings_delete_handler), ) // LLM utilities - .route("/api/llm/test_connection", post(llm_test_connection_handler)) + .route( + "/api/llm/test_connection", + post(llm_test_connection_handler), + ) .route("/api/llm/list_models", post(llm_list_models_handler)) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) @@ -2513,95 +2520,6 @@ async fn routines_runs_handler( }))) } -// --- Settings handlers --- - -async fn settings_list_handler( - State(state): State>, -) -> Result, StatusCode> { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let rows = store.list_settings(&state.user_id).await.map_err(|e| { - tracing::error!("Failed to list settings: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let settings = rows - .into_iter() - .map(|r| SettingResponse { - key: r.key, - value: r.value, - updated_at: r.updated_at.to_rfc3339(), - }) - .collect(); - - Ok(Json(SettingsListResponse { settings })) -} - -async fn settings_get_handler( - State(state): State>, - Path(key): Path, -) -> Result, StatusCode> { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let row = store - .get_setting_full(&state.user_id, &key) - .await - .map_err(|e| { - tracing::error!("Failed to get setting '{}': {}", key, e); - StatusCode::INTERNAL_SERVER_ERROR - })? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(SettingResponse { - key: row.key, - value: row.value, - updated_at: row.updated_at.to_rfc3339(), - })) -} - -async fn settings_set_handler( - State(state): State>, - Path(key): Path, - Json(body): Json, -) -> Result { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - store - .set_setting(&state.user_id, &key, &body.value) - .await - .map_err(|e| { - tracing::error!("Failed to set setting '{}': {}", key, e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(StatusCode::NO_CONTENT) -} - -async fn settings_delete_handler( - State(state): State>, - Path(key): Path, -) -> Result { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - store - .delete_setting(&state.user_id, &key) - .await - .map_err(|e| { - tracing::error!("Failed to delete setting '{}': {}", key, e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(StatusCode::NO_CONTENT) -} - #[derive(serde::Deserialize)] struct TestConnectionRequest { adapter: String, @@ -2634,7 +2552,7 @@ async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionR return TestConnectionResponse { ok: false, message: format!("Failed to build HTTP client: {e}"), - } + }; } }; @@ -2659,7 +2577,12 @@ async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionR } } "anthropic" => { - let url = format!("{base}/messages"); + let anthropic_base = if base.ends_with("/v1") || base.contains("/v1/") { + base.to_string() + } else { + format!("{base}/v1") + }; + let url = format!("{anthropic_base}/messages"); let model = req.model.as_deref().unwrap_or("claude-3-haiku-20240307"); let payload = serde_json::json!({ "model": model, @@ -2700,7 +2623,10 @@ fn interpret_chat_response( Ok(r) => { let status = r.status(); if status.is_success() { - TestConnectionResponse { ok: true, message: format!("Connected ({})", status) } + TestConnectionResponse { + ok: true, + message: format!("Connected ({})", status), + } } else if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { @@ -2708,12 +2634,19 @@ fn interpret_chat_response( ok: false, message: format!("Authentication failed ({})", status), } - } else if status.is_client_error() { + } else if status == reqwest::StatusCode::BAD_REQUEST + || status == reqwest::StatusCode::UNPROCESSABLE_ENTITY + { // 400/422 = server reachable, likely wrong model name — still a success for connectivity TestConnectionResponse { ok: true, message: format!("Server reachable ({})", status), } + } else if status.is_client_error() { + TestConnectionResponse { + ok: false, + message: format!("Client error ({})", status), + } } else { TestConnectionResponse { ok: false, @@ -2721,7 +2654,10 @@ fn interpret_chat_response( } } } - Err(e) => TestConnectionResponse { ok: false, message: format!("Connection failed: {e}") }, + Err(e) => TestConnectionResponse { + ok: false, + message: format!("Connection failed: {e}"), + }, } } @@ -2740,9 +2676,7 @@ struct ListModelsResponse { message: String, } -async fn llm_list_models_handler( - Json(body): Json, -) -> Json { +async fn llm_list_models_handler(Json(body): Json) -> Json { Json(fetch_provider_models(body).await) } @@ -2757,7 +2691,7 @@ async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse { ok: false, models: vec![], message: format!("Failed to build HTTP client: {e}"), - } + }; } }; @@ -2806,19 +2740,22 @@ async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse { } _ => { // OpenAI-compatible and Anthropic both support GET /models - let url = format!("{base}/models"); + let effective_base = + if req.adapter == "anthropic" && !base.ends_with("/v1") && !base.contains("/v1/") { + format!("{base}/v1") + } else { + base.to_string() + }; + let url = format!("{effective_base}/models"); let mut builder = client.get(&url); - if let Some(key) = auth { - builder = builder.header("Authorization", format!("Bearer {key}")); - } - // Anthropic also needs the version header and uses x-api-key if req.adapter == "anthropic" { + // Anthropic requires a version header and uses x-api-key for authentication + builder = builder.header("anthropic-version", "2023-06-01"); if let Some(key) = auth { - builder = client - .get(&url) - .header("x-api-key", key) - .header("anthropic-version", "2023-06-01"); + builder = builder.header("x-api-key", key); } + } else if let Some(key) = auth { + builder = builder.header("Authorization", format!("Bearer {key}")); } match builder.send().await { Ok(r) if r.status().is_success() => { @@ -2862,40 +2799,6 @@ async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse { } } -async fn settings_export_handler( - State(state): State>, -) -> Result, StatusCode> { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { - tracing::error!("Failed to export settings: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(SettingsExportResponse { settings })) -} - -async fn settings_import_handler( - State(state): State>, - Json(body): Json, -) -> Result { - let store = state - .store - .as_ref() - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - store - .set_all_settings(&state.user_id, &body.settings) - .await - .map_err(|e| { - tracing::error!("Failed to import settings: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(StatusCode::NO_CONTENT) -} - // --- Gateway control plane handlers --- async fn gateway_status_handler( diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index e5c43296..9d92b6f9 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -1880,7 +1880,6 @@ function switchTab(tab) { if (tab === 'jobs') loadJobs(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); - if (tab === 'config') loadConfig(); if (tab === 'settings') { loadSettingsSubtab(currentSettingsSubtab); } else { @@ -4606,6 +4605,7 @@ function loadSettingsSubtab(subtab) { else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); } else if (subtab === 'mcp') loadMcpServers(); else if (subtab === 'skills') loadSkills(); + else if (subtab === 'providers') loadConfig(); if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll(); } @@ -5553,7 +5553,7 @@ const BUILTIN_PROVIDERS = [ { 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 }, + { id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true, default_model: 'anthropic.claude-3-sonnet-20240229-v1:0', api_key_required: false, can_list_models: false }, ]; const ADAPTER_LABELS = { @@ -5680,11 +5680,11 @@ function setActiveProvider(id) { (provider && provider.default_model) || null; const defaultModel = restoredModel; - const modelUpdate = defaultModel + const modelUpdate = () => defaultModel ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } }) : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }); apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } }) - .then(() => modelUpdate) + .then(() => modelUpdate()) .then(() => { _activeLlmBackend = id; _selectedModel = defaultModel || ''; @@ -5702,10 +5702,14 @@ function deleteCustomProvider(id) { return; } if (!confirm(I18n.t('config.confirmDeleteProvider', { id }))) return; + const originalProviders = _customProviders; _customProviders = _customProviders.filter((p) => p.id !== id); saveCustomProviders().then(() => { renderProviders(); showToast(I18n.t('config.providerDeleted')); + }).catch((e) => { + _customProviders = originalProviders; + showToast(I18n.t('error.unknown') + ': ' + e.message, 'error'); }); } @@ -5738,7 +5742,7 @@ function configureBuiltinProvider(id) { if (!p) return; _configuringBuiltinId = id; const titleEl = document.getElementById('provider-form-title'); - titleEl.textContent = I18n.t('config.configureProvider') + ': ' + escHtml(p.name || id); + titleEl.textContent = I18n.t('config.configureProvider') + ': ' + (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'; @@ -5850,13 +5854,15 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { 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(); + const modelUpdate = () => { + if (!isActive) return Promise.resolve(); + if (model) { + return apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } }); + } + return apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }); + }; apiFetchVoid('/api/settings/llm_builtin_overrides', { method: 'PUT', body: { value: _builtinOverrides } }) - .then(() => modelUpdate) + .then(() => modelUpdate()) .then(() => { if (isActive) _selectedModel = model; renderProviders(); @@ -5890,12 +5896,14 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { const original = _customProviders[idx]; _customProviders[idx] = { ...original, name, adapter, base_url: baseUrl, default_model: model || undefined, api_key: apiKey || undefined }; const isActive = _editingProviderId === _activeLlmBackend; - const modelUpdate = isActive - ? (model - ? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } }) - : apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' })) - : Promise.resolve(); - saveCustomProviders().then(() => modelUpdate).then(() => { + const modelUpdate = () => { + if (!isActive) return Promise.resolve(); + if (model) { + return apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } }); + } + return apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }); + }; + saveCustomProviders().then(() => modelUpdate()).then(() => { if (isActive) _selectedModel = model; renderProviders(); resetProviderForm(); @@ -5948,6 +5956,7 @@ function resetProviderForm() { const idField = document.getElementById('provider-id'); idField.readOnly = false; idField.style.opacity = ''; + delete idField.dataset.edited; const baseUrlField = document.getElementById('provider-base-url'); baseUrlField.readOnly = false; baseUrlField.style.opacity = ''; diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index fe3b8b5d..6c217854 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -39,6 +39,7 @@ I18n.register('en', { 'settings.channels': 'Channels', 'settings.networking': 'Networking', 'settings.mcp': 'MCP', + 'settings.providers': 'Providers', // Status 'status.connected': 'Connected', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index cb31fc36..22fee070 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -39,6 +39,7 @@ I18n.register('zh-CN', { 'settings.channels': '频道', 'settings.networking': '网络', 'settings.mcp': 'MCP', + 'settings.providers': '模型提供商', // 状态 'status.connected': '已连接', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 250cb515..dea29cbd 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -149,7 +149,6 @@ -
@@ -336,6 +335,7 @@ +
@@ -431,25 +431,23 @@
- - - - - - -
-
-
-
-

Model Providers

- -
- -
-
Loading...
+
+
+
+
+

Model Providers

+ +
+ +
+
Loading...
+
+
+
+
diff --git a/src/config/llm.rs b/src/config/llm.rs index bd56bec8..1b3a0f5f 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -86,7 +86,11 @@ impl LlmConfig { .iter() .find(|p| p.id.to_lowercase() == backend_lower); - if !is_nearai && !is_bedrock && custom_provider.is_none() && registry.find(&backend_lower).is_none() { + if !is_nearai + && !is_bedrock + && custom_provider.is_none() + && registry.find(&backend_lower).is_none() + { tracing::warn!( "Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.", backend @@ -1170,7 +1174,10 @@ mod tests { assert_eq!(provider.provider_id, "myprovider"); assert_eq!(provider.base_url, "https://api.example.com/v1"); assert_eq!(provider.model, "my-model"); - assert_eq!(provider.protocol, crate::llm::registry::ProviderProtocol::OpenAiCompletions); + assert_eq!( + provider.protocol, + crate::llm::registry::ProviderProtocol::OpenAiCompletions + ); } #[test] @@ -1220,7 +1227,10 @@ mod tests { 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()) }, + crate::settings::LlmBuiltinOverride { + api_key: None, + model: Some("llama-3.1-8b-instant".to_string()), + }, ); let settings = Settings { llm_backend: Some("groq".to_string()), @@ -1248,7 +1258,10 @@ mod tests { 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()) }, + crate::settings::LlmBuiltinOverride { + api_key: None, + model: Some("llama-3.1-8b-instant".to_string()), + }, ); let settings = Settings { llm_backend: Some("groq".to_string()), @@ -1292,9 +1305,12 @@ mod tests { 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"); + let key = provider + .api_key + .expect("api_key should be set from builtin override"); assert_eq!( - key.expose_secret(), "gsk_test_key", + key.expose_secret(), + "gsk_test_key", "builtin override api_key should be used when env var is absent" ); } diff --git a/src/settings.rs b/src/settings.rs index 1750f697..78fe3934 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,7 +1,10 @@ //! User settings persistence. //! -//! Stores user preferences in ~/.ironclaw/settings.json. -//! Settings are loaded with env var > settings.json > default priority. +//! Stores user preferences in `~/.ironclaw` (JSON/TOML) and, for some values, +//! in the database. At runtime, settings are resolved using the following +//! precedence: database > environment variables > on-disk config > built-in +//! defaults. In particular, LLM backend and related settings prefer DB values +//! over environment variables. use std::collections::HashMap; use std::path::PathBuf; @@ -37,12 +40,15 @@ pub struct CustomLlmProviderSettings { /// /// Stored as `llm_builtin_overrides` in the settings store, keyed by provider ID /// (e.g. `"openai"`, `"gemini"`). Resolved at startup during `LlmConfig::resolve()`. +/// +/// Note: Environment variables and the global `selected_model` (if set) take +/// precedence over these per-provider overrides. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LlmBuiltinOverride { - /// API key override (takes precedence over env var). + /// API key override used when no API key is provided via environment variables. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key: Option, - /// Default model override (takes precedence over global `selected_model`). + /// Default model override used when no global `selected_model` is configured. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, }