diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index fb12817..cbe4a4d 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -1496,14 +1496,28 @@ fn trim_non_empty(value: Option) -> Option { }) } -fn important_env_targets(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { +/// Primary env var keys for each agent type: (api_base_url, api_key, model). +/// Shared by runtime env resolution, model-provider cascade, and config patching. +fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { match agent_type { - AgentType::ClaudeCode => ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL"), + AgentType::ClaudeCode => ("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL"), AgentType::Gemini => ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"), _ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"), } } +/// Serialize a BTreeMap into env_json for database storage. +/// Returns `None` when the map is empty. +fn serialize_env_map(env: &BTreeMap) -> Result, AcpError> { + if env.is_empty() { + Ok(None) + } else { + serde_json::to_string(env) + .map(Some) + .map_err(|e| AcpError::protocol(e.to_string())) + } +} + pub(crate) fn build_runtime_env_from_setting( agent_type: AgentType, setting: Option<&crate::db::entities::agent_setting::Model>, @@ -1529,7 +1543,7 @@ pub(crate) fn build_runtime_env_from_setting( merged.insert(key, trimmed.to_string()); } - let (api_base_url_key, api_key_key, model_key) = important_env_targets(agent_type); + let (api_base_url_key, api_key_key, model_key) = agent_env_keys(agent_type); if let Some(value) = trim_non_empty(config.api_base_url) { merged.insert(api_base_url_key.to_string(), value); } @@ -1560,7 +1574,7 @@ pub(crate) async fn apply_model_provider_env( Ok(Some(p)) => p, _ => return, }; - let (url_key, key_key, _) = important_env_targets(agent_type); + let (url_key, key_key, _) = agent_env_keys(agent_type); if !provider.api_url.trim().is_empty() { runtime_env.insert(url_key.to_string(), provider.api_url.clone()); } @@ -1569,6 +1583,149 @@ pub(crate) async fn apply_model_provider_env( } } +/// Update on-disk config files for a single agent when model provider credentials change. +/// Uses `agent_env_keys` to determine the correct env var names per agent type. +fn cascade_update_agent_config( + agent_type: AgentType, + api_url: &str, + api_key: &str, +) -> Result<(), AcpError> { + let (url_key, key_key, _) = agent_env_keys(agent_type); + match agent_type { + AgentType::ClaudeCode | AgentType::Gemini => { + // Write into config.env (not root-level) + let mut env = serde_json::Map::new(); + env.insert(url_key.to_string(), serde_json::Value::String(api_url.to_string())); + env.insert(key_key.to_string(), serde_json::Value::String(api_key.to_string())); + let patch = serde_json::json!({ "env": env }); + let patch_str = serde_json::to_string(&patch) + .map_err(|e| AcpError::protocol(e.to_string()))?; + persist_agent_local_config_json(agent_type, Some(&patch_str))?; + } + AgentType::OpenClaw => { + // agent_local_config_path returns None for OpenClaw — no-op + } + AgentType::Codex => { + let auth_path = codex_auth_json_path(); + let mut auth_obj = if auth_path.exists() { + fs::read_to_string(&auth_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|v| v.is_object()) + .unwrap_or_else(|| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + if !api_key.trim().is_empty() { + auth_obj[key_key] = serde_json::Value::String(api_key.to_string()); + } + let auth_str = serde_json::to_string_pretty(&auth_obj) + .map_err(|e| AcpError::protocol(e.to_string()))?; + + let config_path = codex_config_toml_path(); + let mut toml_value = if config_path.exists() { + fs::read_to_string(&config_path) + .ok() + .and_then(|raw| raw.parse::().ok()) + .filter(|v| v.is_table()) + .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new())) + } else { + toml::Value::Table(toml::map::Map::new()) + }; + if let Some(table) = toml_value.as_table_mut() { + if api_url.trim().is_empty() { + table.remove("api_base_url"); + } else { + table.insert("api_base_url".to_string(), toml::Value::String(api_url.to_string())); + } + } + let toml_str = toml::to_string_pretty(&toml_value) + .map_err(|e| AcpError::protocol(e.to_string()))?; + + persist_codex_native_config_files(Some(&auth_str), Some(&toml_str))?; + } + AgentType::OpenCode => { + let auth_path = opencode_auth_json_path(); + let mut auth_obj = if auth_path.exists() { + fs::read_to_string(&auth_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|v| v.is_object()) + .unwrap_or_else(|| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + if !api_key.trim().is_empty() { + auth_obj["api_key"] = serde_json::Value::String(api_key.to_string()); + } + let auth_str = serde_json::to_string_pretty(&auth_obj) + .map_err(|e| AcpError::protocol(e.to_string()))?; + persist_opencode_auth_json(&auth_str)?; + + let patch = serde_json::json!({ "apiBaseUrl": api_url }); + let patch_str = serde_json::to_string(&patch) + .map_err(|e| AcpError::protocol(e.to_string()))?; + persist_agent_local_config_json(agent_type, Some(&patch_str))?; + } + AgentType::Cline => {} + } + Ok(()) +} + +/// Cascade model provider credential changes to all dependent agent settings and config files. +pub(crate) async fn cascade_update_model_provider( + db: &AppDatabase, + provider_id: i32, + new_api_url: &str, + new_api_key: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + let dependents = agent_setting_service::find_by_model_provider_id(&db.conn, provider_id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + + for setting in &dependents { + let agent_type: AgentType = match serde_json::from_str(&setting.agent_type) { + Ok(at) => at, + Err(_) => continue, + }; + + // 1. Update env_json in database (uses agent_env_keys for consistent key names) + let (url_key, key_key, _) = agent_env_keys(agent_type); + let mut env_map: BTreeMap = setting + .env_json + .as_deref() + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default(); + + if !new_api_url.trim().is_empty() { + env_map.insert(url_key.to_string(), new_api_url.to_string()); + } + if !new_api_key.trim().is_empty() { + env_map.insert(key_key.to_string(), new_api_key.to_string()); + } + + let patch = agent_setting_service::AgentSettingsUpdate { + enabled: setting.enabled, + env_json: serialize_env_map(&env_map)?, + model_provider_id: setting.model_provider_id, + }; + agent_setting_service::update(&db.conn, agent_type, patch) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + + // 2. Update on-disk config files + if let Err(e) = cascade_update_agent_config(agent_type, new_api_url, new_api_key) { + eprintln!( + "[ModelProvider] cascade_update_agent_config({agent_type}) failed: {e}, skipping config update" + ); + } + + emit_acp_agents_updated(emitter, "env_updated", Some(agent_type)); + } + Ok(()) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_preflight( agent_type: AgentType, @@ -1828,7 +1985,7 @@ pub(crate) async fn acp_list_agents_core( } env.insert(key, trimmed.to_string()); } - let (api_base_url_key, api_key_key, model_key) = important_env_targets(agent_type); + let (api_base_url_key, api_key_key, model_key) = agent_env_keys(agent_type); if let Some(value) = trim_non_empty(local_cfg.api_base_url) { env.insert(api_base_url_key.to_string(), value); } @@ -1946,11 +2103,7 @@ pub(crate) async fn acp_update_agent_preferences_core( .await .map_err(|e| AcpError::protocol(e.to_string()))?; - let env_json = if env.is_empty() { - None - } else { - Some(serde_json::to_string(&env).map_err(|e| AcpError::protocol(e.to_string()))?) - }; + let env_json = serialize_env_map(&env)?; let config_json = config_json.and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -2074,15 +2227,9 @@ pub(crate) async fn acp_update_agent_env_core( .await .map_err(|e| AcpError::protocol(e.to_string()))?; - let env_json = if env.is_empty() { - None - } else { - Some(serde_json::to_string(&env).map_err(|e| AcpError::protocol(e.to_string()))?) - }; - let patch = agent_setting_service::AgentSettingsUpdate { enabled, - env_json, + env_json: serialize_env_map(&env)?, model_provider_id, }; agent_setting_service::update(&db.conn, agent_type, patch) diff --git a/src-tauri/src/commands/model_provider.rs b/src-tauri/src/commands/model_provider.rs index d47b070..6831c42 100644 --- a/src-tauri/src/commands/model_provider.rs +++ b/src-tauri/src/commands/model_provider.rs @@ -1,8 +1,10 @@ use crate::app_error::AppCommandError; +use crate::commands::acp; use crate::db::service::{agent_setting_service, model_provider_service}; use crate::db::AppDatabase; use crate::models::agent::AgentType; use crate::models::model_provider::ModelProviderInfo; +use crate::web::event_bridge::EventEmitter; // --------------------------------------------------------------------------- // Shared core functions (used by both Tauri commands and web handlers) @@ -89,6 +91,7 @@ pub async fn update_model_provider_core( api_url: Option, api_key: Option, agent_types: Option>, + emitter: &EventEmitter, ) -> Result { validate_fields(name.as_deref(), api_url.as_deref(), api_key.as_deref())?; let agent_types_json = if let Some(ref ats) = agent_types { @@ -101,16 +104,34 @@ pub async fn update_model_provider_core( None }; + // Fetch old provider to detect credential changes. + let old_provider = model_provider_service::get_by_id(&db.conn, id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found(format!("model provider not found: {id}")))?; + let model = model_provider_service::update( &db.conn, id, name, - api_url, - api_key, + api_url.clone(), + api_key.clone(), agent_types_json, ) .await .map_err(AppCommandError::from)?; + + // Cascade credential changes to all dependent agent settings and config files. + let url_changed = api_url.as_deref().is_some_and(|u| u != old_provider.api_url); + let key_changed = api_key.as_deref().is_some_and(|k| k != old_provider.api_key); + if url_changed || key_changed { + let final_url = api_url.as_deref().unwrap_or(&old_provider.api_url); + let final_key = api_key.as_deref().unwrap_or(&old_provider.api_key); + acp::cascade_update_model_provider(db, id, final_url, final_key, emitter) + .await + .map_err(|e| AppCommandError::invalid_input(e.to_string()))?; + } + Ok(ModelProviderInfo::from(model)) } @@ -118,10 +139,26 @@ pub async fn delete_model_provider_core( db: &AppDatabase, id: i32, ) -> Result<(), AppCommandError> { - // Clear any agent settings that reference this provider before deleting. - agent_setting_service::clear_model_provider_id(&db.conn, id) + // Check if any agent settings reference this provider. + let dependents = agent_setting_service::find_by_model_provider_id(&db.conn, id) .await .map_err(AppCommandError::from)?; + + if !dependents.is_empty() { + let agent_names: Vec = dependents + .iter() + .filter_map(|row| { + serde_json::from_str::(&row.agent_type) + .ok() + .map(|at| at.to_string()) + }) + .collect(); + let names_joined = agent_names.join(", "); + return Err(AppCommandError::invalid_input(format!( + "PROVIDER_IN_USE:{names_joined}" + ))); + } + model_provider_service::delete(&db.conn, id) .await .map_err(AppCommandError::from)?; @@ -161,8 +198,10 @@ pub async fn update_model_provider( api_url: Option, api_key: Option, agent_types: Option>, + app: tauri::AppHandle, ) -> Result { - update_model_provider_core(&db, id, name, api_url, api_key, agent_types).await + let emitter = EventEmitter::Tauri(app); + update_model_provider_core(&db, id, name, api_url, api_key, agent_types, &emitter).await } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 777f8a5..7ee2fce 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -205,23 +205,15 @@ async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> R Ok(()) } -pub async fn clear_model_provider_id( +pub async fn find_by_model_provider_id( conn: &DatabaseConnection, model_provider_id: i32, -) -> Result<(), DbError> { +) -> Result, DbError> { let rows = agent_setting::Entity::find() + .filter(agent_setting::Column::ModelProviderId.eq(Some(model_provider_id))) .all(conn) .await?; - let now = Utc::now(); - for row in rows { - if row.model_provider_id == Some(model_provider_id) { - let mut active = row.into_active_model(); - active.model_provider_id = Set(None); - active.updated_at = Set(now); - active.update(conn).await?; - } - } - Ok(()) + Ok(rows) } fn is_sqlite_full_error(err: &DbError) -> bool { diff --git a/src-tauri/src/web/handlers/model_provider.rs b/src-tauri/src/web/handlers/model_provider.rs index ea9054f..f09f831 100644 --- a/src-tauri/src/web/handlers/model_provider.rs +++ b/src-tauri/src/web/handlers/model_provider.rs @@ -74,6 +74,7 @@ pub async fn update_model_provider( params.api_url, params.api_key, params.agent_types, + &state.emitter, ) .await?; Ok(Json(result)) diff --git a/src/components/settings/add-model-provider-dialog.tsx b/src/components/settings/add-model-provider-dialog.tsx index d33f427..e491cec 100644 --- a/src/components/settings/add-model-provider-dialog.tsx +++ b/src/components/settings/add-model-provider-dialog.tsx @@ -89,8 +89,14 @@ export function AddModelProviderDialog({ toast.success(t("createSuccess")) handleOpenChange(false) onProviderAdded() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) + } catch (err: unknown) { + const raw = err as Record + const msg = + typeof raw?.message === "string" + ? raw.message + : err instanceof Error + ? err.message + : String(err) setError(msg) } finally { setLoading(false) diff --git a/src/components/settings/edit-model-provider-dialog.tsx b/src/components/settings/edit-model-provider-dialog.tsx index aa9b0be..9a27773 100644 --- a/src/components/settings/edit-model-provider-dialog.tsx +++ b/src/components/settings/edit-model-provider-dialog.tsx @@ -97,8 +97,14 @@ export function EditModelProviderDialog({ toast.success(t("editSuccess")) handleOpenChange(false) onProviderUpdated() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) + } catch (err: unknown) { + const raw = err as Record + const msg = + typeof raw?.message === "string" + ? raw.message + : err instanceof Error + ? err.message + : String(err) setError(msg) } finally { setLoading(false) diff --git a/src/components/settings/model-provider-settings.tsx b/src/components/settings/model-provider-settings.tsx index 9ef04ce..a8014e3 100644 --- a/src/components/settings/model-provider-settings.tsx +++ b/src/components/settings/model-provider-settings.tsx @@ -72,9 +72,21 @@ export function ModelProviderSettings() { toast.success(t("deleteSuccess")) setDeleteTarget(null) await loadProviders() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - toast.error(msg) + } catch (err: unknown) { + const raw = err as Record + const msg = + typeof raw?.message === "string" + ? raw.message + : err instanceof Error + ? err.message + : String(err) + const prefix = "PROVIDER_IN_USE:" + if (msg.includes(prefix)) { + const agentNames = msg.substring(msg.indexOf(prefix) + prefix.length) + toast.error(t("deleteBlockedByAgent", { agents: agentNames })) + } else { + toast.error(msg) + } } }, [deleteTarget, loadProviders, t]) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3501a67..5228225 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "تم حذف المزود.", "deleteConfirmTitle": "حذف المزود", "deleteConfirmMessage": "سيتم حذف المزود \"{name}\" نهائياً. هل أنت متأكد؟", + "deleteBlockedByAgent": "{agents} يستخدم هذا المزود. يرجى إلغاء الربط قبل الحذف.", "cancel": "إلغاء", "delete": "حذف", "create": "إنشاء", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 2545bd9..6484130 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "Anbieter gelöscht.", "deleteConfirmTitle": "Anbieter löschen", "deleteConfirmMessage": "Der Anbieter \"{name}\" wird dauerhaft gelöscht. Sind Sie sicher?", + "deleteBlockedByAgent": "{agents} verwendet diesen Anbieter. Bitte trennen Sie die Verbindung vor dem Löschen.", "cancel": "Abbrechen", "delete": "Löschen", "create": "Erstellen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0e869f4..59ff902 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "Provider deleted.", "deleteConfirmTitle": "Delete Provider", "deleteConfirmMessage": "This will permanently delete the provider \"{name}\". Are you sure?", + "deleteBlockedByAgent": "{agents} is currently using this provider. Please unlink before deleting.", "cancel": "Cancel", "delete": "Delete", "create": "Create", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f5c608a..e397094 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "Proveedor eliminado.", "deleteConfirmTitle": "Eliminar Proveedor", "deleteConfirmMessage": "Esto eliminará permanentemente el proveedor \"{name}\". ¿Está seguro?", + "deleteBlockedByAgent": "{agents} está usando este proveedor. Desvincúlelo antes de eliminarlo.", "cancel": "Cancelar", "delete": "Eliminar", "create": "Crear", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 427a413..e63f7e3 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "Fournisseur supprimé.", "deleteConfirmTitle": "Supprimer le Fournisseur", "deleteConfirmMessage": "Le fournisseur \"{name}\" sera définitivement supprimé. Êtes-vous sûr ?", + "deleteBlockedByAgent": "{agents} utilise ce fournisseur. Veuillez le dissocier avant de supprimer.", "cancel": "Annuler", "delete": "Supprimer", "create": "Créer", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 0a866f2..64ac549 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "プロバイダーを削除しました。", "deleteConfirmTitle": "プロバイダーを削除", "deleteConfirmMessage": "プロバイダー「{name}」を完全に削除しますか?", + "deleteBlockedByAgent": "{agents} がこのプロバイダーを使用中です。削除する前にリンクを解除してください。", "cancel": "キャンセル", "delete": "削除", "create": "作成", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ec53272..c27145b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "제공업체가 삭제되었습니다.", "deleteConfirmTitle": "제공업체 삭제", "deleteConfirmMessage": "제공업체 \"{name}\"을(를) 영구적으로 삭제하시겠습니까?", + "deleteBlockedByAgent": "{agents}이(가) 이 공급자를 사용 중입니다. 삭제하기 전에 연결을 해제해 주세요.", "cancel": "취소", "delete": "삭제", "create": "생성", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ee6186b..c94d6b6 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "Provedor excluído.", "deleteConfirmTitle": "Excluir Provedor", "deleteConfirmMessage": "O provedor \"{name}\" será excluído permanentemente. Tem certeza?", + "deleteBlockedByAgent": "{agents} está usando este provedor. Desvincule-o antes de excluir.", "cancel": "Cancelar", "delete": "Excluir", "create": "Criar", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 144352a..475a7fb 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "供应商已删除。", "deleteConfirmTitle": "删除供应商", "deleteConfirmMessage": "确定要永久删除供应商「{name}」吗?", + "deleteBlockedByAgent": "{agents} 正在使用该配置,请先解除关联后再删除。", "cancel": "取消", "delete": "删除", "create": "创建", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a5b7808..f058dba 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1849,6 +1849,7 @@ "deleteSuccess": "供應商已刪除。", "deleteConfirmTitle": "刪除供應商", "deleteConfirmMessage": "確定要永久刪除供應商「{name}」嗎?", + "deleteBlockedByAgent": "{agents} 正在使用此設定,請先解除關聯後再刪除。", "cancel": "取消", "delete": "刪除", "create": "建立",