//! Provider service module //! //! Handles provider CRUD operations, switching, and configuration management. mod endpoints; mod gemini_auth; mod live; mod usage; use indexmap::IndexMap; use regex::Regex; use serde::Deserialize; use serde_json::Value; use crate::app_config::AppType; use crate::database::{validate_cost_multiplier, validate_pricing_source}; use crate::error::AppError; use crate::provider::{Provider, UsageResult}; use crate::services::mcp::McpService; use crate::settings::CustomEndpoint; use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, should_import_default_config_on_startup, sync_current_to_live, update_toml_common_config_snippet, }; // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ build_effective_provider_for_live_with_codex_oauth_manager, build_effective_settings_with_common_config, normalize_provider_common_config_for_storage, provider_exists_in_live_config, strip_common_config_from_live_settings, sync_current_provider_for_app_to_live, write_live_with_common_config_for_codex_oauth_manager, write_live_with_common_config_for_state, }; // Internal re-exports use live::{ remove_hermes_provider_from_live, remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, }; use usage::validate_usage_script; /// The built-in Codex official provider is safe to select during takeover: /// Codex keeps ownership of its ChatGPT login and the proxy only forwards the /// authenticated request. Other official providers retain the existing block. pub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool { matches!(app_type, AppType::Codex) && crate::proxy::providers::is_codex_official_provider(provider) } /// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的 /// live 配置,使开关即时生效(无需等下一次切换)。 /// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置, /// 第三方 live 配置不受开关影响。 pub fn reapply_current_codex_official_live(state: &AppState) -> Result { let current_id = ProviderService::current(state, AppType::Codex)?; if current_id.is_empty() { return Ok(false); } let providers = state.db.get_all_providers(AppType::Codex.as_str())?; let Some(provider) = providers.get(¤t_id) else { return Ok(false); }; if provider.category.as_deref() != Some("official") { return Ok(false); } // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截, // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号, // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。 let has_live_backup = futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex); if has_live_backup || live_taken_over { futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(AppType::Codex.as_str(), provider), ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; return Ok(true); } live::write_live_with_common_config_for_state(state, &AppType::Codex, provider)?; // 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失, // 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非 // sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex // 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex // 的重投影,让刚被清掉的 [mcp_servers] 无人补回。 // 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上 // 已生效;若把错误上抛,save_settings 会回滚开关设置,制造"设置=旧值、 // live=新桶"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈 // (下次切换 / 任一 MCP 启停操作都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) { log::warn!("统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}"); } Ok(true) } /// Provider business logic service pub struct ProviderService; /// Result of a provider switch operation, including any non-fatal warnings #[derive(Debug, serde::Serialize, Default)] #[serde(rename_all = "camelCase")] pub struct SwitchResult { pub warnings: Vec, } #[cfg(test)] mod tests { use super::*; #[cfg(any(target_os = "macos", windows))] use crate::claude_desktop_config::PROFILE_ID; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta, UsageScript}; #[cfg(any(target_os = "macos", windows))] use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute}; use crate::proxy::types::ProxyConfig; use crate::store::AppState; use serde_json::json; use serial_test::serial; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use tempfile::TempDir; struct TempHome { #[allow(dead_code)] dir: TempDir, original_home: Option, #[cfg(windows)] original_local_app_data: Option, original_userprofile: Option, original_test_home: Option, } impl TempHome { fn new() -> Self { let dir = TempDir::new().expect("failed to create temp home"); let original_home = env::var("HOME").ok(); #[cfg(windows)] let original_local_app_data = env::var("LOCALAPPDATA").ok(); let original_userprofile = env::var("USERPROFILE").ok(); let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); env::set_var("HOME", dir.path()); #[cfg(windows)] env::set_var("LOCALAPPDATA", dir.path().join("AppData").join("Local")); env::set_var("USERPROFILE", dir.path()); env::set_var("CC_SWITCH_TEST_HOME", dir.path()); Self { dir, original_home, #[cfg(windows)] original_local_app_data, original_userprofile, original_test_home, } } } impl Drop for TempHome { fn drop(&mut self) { match &self.original_home { Some(value) => env::set_var("HOME", value), None => env::remove_var("HOME"), } #[cfg(windows)] { match &self.original_local_app_data { Some(value) => env::set_var("LOCALAPPDATA", value), None => env::remove_var("LOCALAPPDATA"), } } match &self.original_userprofile { Some(value) => env::set_var("USERPROFILE", value), None => env::remove_var("USERPROFILE"), } match &self.original_test_home { Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value), None => env::remove_var("CC_SWITCH_TEST_HOME"), } } } #[cfg(windows)] fn claude_desktop_profile_path(home: &Path) -> PathBuf { home.join("AppData") .join("Local") .join("Claude-3p") .join("configLibrary") .join(format!("{PROFILE_ID}.json")) } #[cfg(target_os = "macos")] fn claude_desktop_profile_path(home: &Path) -> PathBuf { home.join("Library") .join("Application Support") .join("Claude-3p") .join("configLibrary") .join(format!("{PROFILE_ID}.json")) } fn test_guard() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|err| err.into_inner()) } fn with_test_home(test: impl FnOnce(&AppState, &Path) -> T) -> T { let _guard = test_guard(); let temp = tempfile::tempdir().expect("tempdir"); let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); let old_home = std::env::var_os("HOME"); std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); std::env::set_var("HOME", temp.path()); let db = Arc::new(Database::memory().expect("in-memory database")); let state = AppState::new(db); let result = test(&state, temp.path()); match old_test_home { Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), None => std::env::remove_var("CC_SWITCH_TEST_HOME"), } match old_home { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } result } fn codex_settings(base_url: &str, api_key: &str) -> Value { json!({ "auth": { "OPENAI_API_KEY": api_key }, "config": format!( "model_provider = \"custom\"\n\ [model_providers.custom]\n\ name = \"custom\"\n\ base_url = \"{base_url}\"\n\ wire_api = \"chat\"\n" ) }) } fn usage_script_with_credentials( api_key: Option<&str>, base_url: Option<&str>, template_type: Option<&str>, ) -> UsageScript { UsageScript { enabled: true, language: "javascript".to_string(), code: "return { remaining: 1, unit: 'USD' };".to_string(), timeout: Some(10), api_key: api_key.map(str::to_string), base_url: base_url.map(str::to_string), access_token: None, user_id: None, template_type: template_type.map(str::to_string), auto_query_interval: None, coding_plan_provider: None, access_key_id: Some("ak-test".to_string()), secret_access_key: Some("sk-test".to_string()), team_organization_id: None, team_project_id: None, } } fn codex_provider_with_usage( id: &str, base_url: &str, api_key: &str, usage_api_key: Option<&str>, usage_base_url: Option<&str>, template_type: Option<&str>, ) -> Provider { let mut provider = Provider::with_id( id.to_string(), format!("Provider {id}"), codex_settings(base_url, api_key), None, ); provider.meta = Some(ProviderMeta { usage_script: Some(usage_script_with_credentials( usage_api_key, usage_base_url, template_type, )), ..Default::default() }); provider } fn openclaw_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "baseUrl": "https://api.deepseek.com", "apiKey": "test-key", "api": "openai-completions", "models": [], }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn hermes_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "api": "openai-chat", "base_url": "https://api.example.com/v1", "api_key": "test-key", "models": { "gpt-4o": { "name": "GPT-4o" } } }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn opencode_provider(id: &str) -> Provider { Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: json!({ "npm": "@ai-sdk/openai-compatible", "name": format!("Provider {id}"), "options": { "baseURL": "https://api.example.com/v1", "apiKey": "test-key" }, "models": { "gpt-4o": { "name": "GPT-4o" } } }), website_url: None, category: Some("custom".to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn opencode_omo_provider(id: &str, category: &str) -> Provider { let mut settings = serde_json::Map::new(); settings.insert( "agents".to_string(), json!({ "writer": { "model": "gpt-4o-mini" } }), ); if category == "omo" { settings.insert( "categories".to_string(), json!({ "default": ["writer"] }), ); } settings.insert( "otherFields".to_string(), json!({ "theme": "dark" }), ); Provider { id: id.to_string(), name: format!("Provider {id}"), settings_config: Value::Object(settings), website_url: None, category: Some(category.to_string()), created_at: Some(1), sort_index: Some(0), notes: None, meta: None, icon: None, icon_color: None, in_failover_queue: false, } } fn omo_config_path(home: &Path, category: &str) -> PathBuf { home.join(".config").join("opencode").join(match category { "omo" => crate::services::omo::STANDARD.preferred_filename, "omo-slim" => crate::services::omo::SLIM.preferred_filename, other => panic!("unexpected OMO category in test: {other}"), }) } #[test] #[serial] fn add_clears_usage_credentials_that_match_provider_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-a", "https://api.a.example/v1/", "sk-a", Some(" sk-a "), Some(" https://api.a.example/v1/ "), None, ); ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); let saved = state .db .get_provider_by_id("codex-a", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key, None); assert_eq!(script.base_url, None); }); } #[test] #[serial] fn update_preserves_usage_credentials_that_only_match_previous_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-usage-old", "https://api.a.example/v1/", "sk-a", Some("sk-a"), Some("https://api.a.example/v1/"), None, ); state .db .save_provider(AppType::Codex.as_str(), &provider) .expect("seed provider with explicit usage credentials"); let mut updated = provider.clone(); updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); ProviderService::update(state, AppType::Codex, None, updated) .expect("update provider main credentials"); let saved = state .db .get_provider_by_id("codex-usage-old", AppType::Codex.as_str()) .expect("query updated provider") .expect("updated provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-a")); assert_eq!( script.base_url.as_deref(), Some("https://api.a.example/v1/") ); assert_eq!( saved.resolve_usage_credentials(&AppType::Codex), ("https://api.b.example/v1".to_string(), "sk-b".to_string()) ); }); } #[test] #[serial] fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() { with_test_home(|state, _| { let copied_provider = codex_provider_with_usage( "codex-copy", "https://api.a.example/v1/", "sk-a", Some("sk-a"), Some("https://api.a.example/v1/"), None, ); ProviderService::add(state, AppType::Codex, copied_provider, false) .expect("add copied provider"); let saved_after_add = state .db .get_provider_by_id("codex-copy", AppType::Codex.as_str()) .expect("query copied provider") .expect("copied provider should exist"); let script_after_add = saved_after_add .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script_after_add.api_key, None); assert_eq!(script_after_add.base_url, None); let mut edited_provider = saved_after_add.clone(); edited_provider.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); ProviderService::update(state, AppType::Codex, None, edited_provider) .expect("edit copied provider credentials"); let saved_after_update = state .db .get_provider_by_id("codex-copy", AppType::Codex.as_str()) .expect("query edited provider") .expect("edited provider should exist"); let script_after_update = saved_after_update .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script_after_update.api_key, None); assert_eq!(script_after_update.base_url, None); assert_eq!( saved_after_update.resolve_usage_credentials(&AppType::Codex), ("https://api.b.example/v1".to_string(), "sk-b".to_string()) ); }); } #[test] #[serial] fn update_clears_usage_credentials_that_match_current_config() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-current", "https://api.a.example/v1", "sk-a", Some("sk-usage"), Some("https://usage.example/api"), None, ); state .db .save_provider(AppType::Codex.as_str(), &provider) .expect("seed provider with distinct usage credentials"); let mut updated = provider.clone(); updated.settings_config = codex_settings("https://api.b.example/v1/", "sk-b"); updated.meta = Some(ProviderMeta { usage_script: Some(usage_script_with_credentials( Some(" sk-b "), Some(" https://api.b.example/v1/ "), None, )), ..Default::default() }); ProviderService::update(state, AppType::Codex, None, updated) .expect("update provider with redundant usage credentials"); let saved = state .db .get_provider_by_id("codex-current", AppType::Codex.as_str()) .expect("query updated provider") .expect("updated provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key, None); assert_eq!(script.base_url, None); }); } #[test] #[serial] fn add_preserves_distinct_usage_credentials() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-distinct", "https://api.main.example/v1", "sk-main", Some("sk-usage"), Some("https://usage.example/api"), None, ); ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); let saved = state .db .get_provider_by_id("codex-distinct", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-usage")); assert_eq!( script.base_url.as_deref(), Some("https://usage.example/api") ); }); } #[test] #[serial] fn add_does_not_clear_token_plan_credentials() { with_test_home(|state, _| { let provider = codex_provider_with_usage( "codex-token-plan", "https://api.plan.example/v1", "sk-plan", Some("sk-plan"), Some("https://api.plan.example/v1"), Some("token_plan"), ); ProviderService::add(state, AppType::Codex, provider, false).expect("add provider"); let saved = state .db .get_provider_by_id("codex-token-plan", AppType::Codex.as_str()) .expect("query saved provider") .expect("saved provider should exist"); let script = saved .meta .as_ref() .and_then(|meta| meta.usage_script.as_ref()) .expect("usage script should remain"); assert_eq!(script.api_key.as_deref(), Some("sk-plan")); assert_eq!( script.base_url.as_deref(), Some("https://api.plan.example/v1") ); assert_eq!(script.access_key_id.as_deref(), Some("ak-test")); assert_eq!(script.secret_access_key.as_deref(), Some("sk-test")); }); } #[test] fn validate_provider_settings_rejects_missing_auth() { let provider = Provider::with_id( "codex".into(), "Codex".into(), json!({ "config": "base_url = \"https://example.com\"" }), None, ); let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider) .expect_err("missing auth should be rejected"); assert!( err.to_string().contains("auth"), "expected auth error, got {err:?}" ); } #[test] fn extract_claude_common_config_strips_all_credentials_keeps_shareable() { // env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex) // 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。 let settings = json!({ "env": { "ANTHROPIC_API_KEY": "sk-ant", "ANTHROPIC_AUTH_TOKEN": "tok-ant", "OPENROUTER_API_KEY": "sk-or", "GOOGLE_API_KEY": "g-key", "OPENAI_API_KEY": "sk-oai", "GEMINI_API_KEY": "g-gem", "AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret", "AWS_SESSION_TOKEN": "sess", "GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json", "AWS_BEARER_TOKEN_BEDROCK": "bedrock-tok", "ANTHROPIC_BASE_URL": "https://example.com", "ANTHROPIC_MODEL": "claude-x", "CLAUDE_CODE_SUBAGENT_MODEL": "gpt-5.4-mini", "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "400000", "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000", // 可共享、非机密配置(复数 _TOKENS 不应被误剥) "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "8192" }, "apiKey": "sk-top", "api_key": "sk-top2", "theme": "dark", "includeCoAuthoredBy": false }); let snippet = ProviderService::extract_claude_common_config(&settings) .expect("extract should succeed"); let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); // 所有凭据都不得出现在共享片段里 let env = value.get("env"); for leaked in [ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENROUTER_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_BEARER_TOKEN_BEDROCK", ] { assert!( env.and_then(|e| e.get(leaked)).is_none(), "credential {leaked} must not leak into common config" ); } assert!( value.get("apiKey").is_none() && value.get("api_key").is_none(), "top-level credentials must be stripped" ); // 端点/模型(provider-specific 非机密)也应剥掉 assert!(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")).is_none()); assert!(env.and_then(|e| e.get("ANTHROPIC_MODEL")).is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_SUBAGENT_MODEL")) .is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_MAX_CONTEXT_TOKENS")) .is_none()); assert!(env .and_then(|e| e.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW")) .is_none()); // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥) assert_eq!( env.and_then(|e| e.get("ENABLE_TOOL_SEARCH")) .and_then(|v| v.as_str()), Some("true") ); assert_eq!( env.and_then(|e| e.get("CLAUDE_CODE_MAX_OUTPUT_TOKENS")) .and_then(|v| v.as_str()), Some("8192") ); assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark")); assert_eq!(value.get("includeCoAuthoredBy"), Some(&json!(false))); } /// Regression for issue #4272: Fable tier env keys must not enter the shared /// Claude common-config snippet (same class as haiku/sonnet/opus model pins). #[test] fn extract_claude_common_config_strips_fable_model_env_keys() { let settings = json!({ "env": { "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Haiku Mapped", "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped[1M]", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Sonnet Mapped", "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped[1M]", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus Mapped", "ANTHROPIC_DEFAULT_FABLE_MODEL": "deepseek-v4-flash[1M]", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME": "deepseek-v4-flash", "ANTHROPIC_MODEL": "default-mapped", "ENABLE_TOOL_SEARCH": "true" }, "theme": "dark" }); let snippet = ProviderService::extract_claude_common_config(&settings) .expect("extract should succeed"); let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON"); let env = value.get("env"); for stripped in [ "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "ANTHROPIC_MODEL", ] { assert!( env.and_then(|e| e.get(stripped)).is_none(), "provider-specific model key {stripped} must not enter common config" ); } assert_eq!( env.and_then(|e| e.get("ENABLE_TOOL_SEARCH")) .and_then(|v| v.as_str()), Some("true") ); assert_eq!(value.get("theme").and_then(|v| v.as_str()), Some("dark")); } #[test] fn validate_provider_settings_rejects_negative_cost_multiplier() { let mut provider = Provider::with_id( "claude".into(), "Claude".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_BASE_URL": "https://claude.example" } }), None, ); provider.meta = Some(ProviderMeta { cost_multiplier: Some("-1".to_string()), ..ProviderMeta::default() }); let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider) .expect_err("negative multiplier should be rejected"); assert!(matches!( err, AppError::Localized { key: "error.invalidMultiplier", .. } )); } #[test] fn extract_credentials_returns_expected_values() { let provider = Provider::with_id( "claude".into(), "Claude".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token", "ANTHROPIC_BASE_URL": "https://claude.example" } }), None, ); let (api_key, base_url) = ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap(); assert_eq!(api_key, "token"); assert_eq!(base_url, "https://claude.example"); } #[test] fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() { // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入; // web_search = "disabled" 是 cc-switch 对黑名单网关注入的哨兵; // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法; // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。 let config_toml = r#"model_provider = "azure" model = "gpt-4" wire_api = "chat" disable_response_storage = true experimental_bearer_token = "sk-live-secret" model_catalog_json = "cc-switch-model-catalog.json" web_search = "disabled" [model_providers.azure] name = "Azure OpenAI" base_url = "https://azure.example/v1" wire_api = "responses" [mcp_servers.my_server] base_url = "http://localhost:8080" [mcp.servers.legacy_server] command = "legacy-cmd" "#; let settings = json!({ "config": config_toml }); let extracted = ProviderService::extract_codex_common_config(&settings) .expect("extract_codex_common_config should succeed"); assert!( !extracted .lines() .any(|line| line.trim_start().starts_with("model_provider")), "should remove top-level model_provider" ); assert!( !extracted .lines() .any(|line| line.trim_start().starts_with("model =")), "should remove top-level model" ); assert!( !extracted.contains("[model_providers"), "should remove entire model_providers table" ); // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers]) assert!( !extracted.contains("mcp_servers") && !extracted.contains("http://localhost:8080"), "should strip mcp_servers from the shared snippet, got: {extracted}" ); assert!( !extracted.contains("[mcp") && !extracted.contains("legacy-cmd"), "should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}" ); // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥, // 剩余任何 wire_api 都意味着泄漏) assert!( !extracted.contains("wire_api"), "should strip top-level wire_api from the shared snippet, got: {extracted}" ); // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题) assert!( !extracted.contains("experimental_bearer_token") && !extracted.contains("sk-live-secret"), "should strip top-level fallback bearer token, got: {extracted}" ); assert!( !extracted.contains("model_catalog_json"), "should strip catalog projection pointer, got: {extracted}" ); assert!( !extracted.contains("web_search"), "should strip the cc-switch web_search disabled sentinel, got: {extracted}" ); // 真正可共享的键保留 assert!( extracted.contains("disable_response_storage = true"), "shareable keys must survive extraction, got: {extracted}" ); } #[test] fn extract_codex_common_config_keeps_user_set_web_search() { let config_toml = "web_search = \"enabled\"\ndisable_response_storage = true\n"; let settings = json!({ "config": config_toml }); let extracted = ProviderService::extract_codex_common_config(&settings) .expect("extract should succeed"); assert!( extracted.contains("web_search = \"enabled\""), "a user-set web_search value is a shareable preference, got: {extracted}" ); } #[tokio::test] #[serial] async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); let db = Arc::new(Database::memory().expect("init db")); let state = AppState::new(db.clone()); let original = Provider::with_id( "p1".into(), "Claude A".into(), json!({ "env": { "ANTHROPIC_API_KEY": "token-a", "ANTHROPIC_BASE_URL": "https://api.a.example", "ANTHROPIC_MODEL": "model-a" }, "permissions": { "allow": ["Bash"] } }), None, ); db.save_provider("claude", &original) .expect("save provider"); db.set_current_provider("claude", "p1") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Claude, Some("p1")) .expect("set local current provider"); db.update_proxy_config(ProxyConfig { live_takeover_active: true, listen_port: 0, ..Default::default() }) .await .expect("update proxy config"); { let mut config = db .get_proxy_config_for_app("claude") .await .expect("get app proxy config"); config.enabled = true; db.update_proxy_config_for_app(config) .await .expect("update app proxy config"); } write_json_file( &get_claude_settings_path(), &json!({ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721", "ANTHROPIC_API_KEY": "PROXY_MANAGED", "ANTHROPIC_MODEL": "stale-model" }, "permissions": { "allow": ["Bash"] } }), ) .expect("seed taken-over live file"); let proxy_info = state .proxy_service .start() .await .expect("start proxy service"); let updated = Provider::with_id( "p1".into(), "Claude A".into(), json!({ "env": { "ANTHROPIC_API_KEY": "token-updated", "ANTHROPIC_BASE_URL": "https://api.updated.example", "ANTHROPIC_MODEL": "model-updated" }, "permissions": { "allow": ["Read"] } }), None, ); ProviderService::update(&state, AppType::Claude, None, updated.clone()) .expect("update current provider"); let backup = db .get_live_backup("claude") .await .expect("get live backup") .expect("backup exists"); let stored_provider = db .get_provider_by_id("p1", "claude") .expect("get stored provider") .expect("stored provider exists"); let expected_backup = serde_json::to_string(&stored_provider.settings_config).expect("serialize"); assert_eq!(backup.original_config, expected_backup); let live: Value = read_json_file(&get_claude_settings_path()).expect("read live"); assert_eq!( live.get("permissions"), updated.settings_config.get("permissions"), "provider edits should propagate into Claude live config during takeover" ); assert_eq!( live.get("env") .and_then(|env| env.get("ANTHROPIC_API_KEY")) .and_then(|v| v.as_str()), Some("PROXY_MANAGED"), "takeover placeholder should stay intact" ); assert_eq!( live.get("env") .and_then(|env| env.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()), Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()), "proxy base URL should stay intact" ); assert!( live.get("env") .and_then(|env| env.get("ANTHROPIC_MODEL")) .is_none(), "model override should be removed in takeover live config" ); } #[tokio::test] #[serial] async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() { let _home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); let db = Arc::new(Database::memory().expect("init db")); let state = AppState::new(db.clone()); let mut original = Provider::with_id( "p1".into(), "Codex A".into(), json!({ "auth": { "OPENAI_API_KEY": "token-a" }, "config": r#"model_provider = "custom" model = "old-model" [model_providers.custom] name = "Codex A" base_url = "https://api.a.example/v1" wire_api = "responses" requires_openai_auth = true "#, "modelCatalog": { "models": [{ "model": "old-model" }] } }), None, ); original.meta = Some(ProviderMeta { api_format: Some("openai_responses".into()), ..Default::default() }); db.save_provider("codex", &original).expect("save provider"); db.set_current_provider("codex", "p1") .expect("set current provider"); crate::settings::set_current_provider(&AppType::Codex, Some("p1")) .expect("set local current provider"); db.update_proxy_config(ProxyConfig { live_takeover_active: true, listen_port: 0, ..Default::default() }) .await .expect("update proxy config"); { let mut config = db .get_proxy_config_for_app("codex") .await .expect("get app proxy config"); config.enabled = true; db.update_proxy_config_for_app(config) .await .expect("enable Codex proxy config"); } db.save_live_backup( "codex", &serde_json::to_string(&original.settings_config).expect("serialize backup"), ) .await .expect("seed live backup"); state .proxy_service .start() .await .expect("start proxy service"); state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&original) .await .expect("seed taken-over Codex live config"); assert!( state .proxy_service .detect_takeover_in_live_config_for_app(&AppType::Codex), "seeded Codex live config should be recognized as takeover-owned" ); let mut updated = original.clone(); updated.settings_config["config"] = json!( r#"model_provider = "custom" model = "gpt-5.4" [model_providers.custom] name = "Codex A" base_url = "https://api.updated.example/v1" wire_api = "responses" requires_openai_auth = true "# ); updated.settings_config["modelCatalog"] = json!({ "models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }] }); ProviderService::update(&state, AppType::Codex, None, updated.clone()) .expect("update current Codex provider mapping"); let catalog_path = crate::codex_config::get_codex_model_catalog_path(); let catalog: Value = read_json_file(&catalog_path).expect("read generated catalog"); assert_eq!(catalog["models"][0]["slug"], "gpt-5.4"); assert_eq!( catalog["models"][0]["input_modalities"], json!(["text", "image"]), "unknown/GPT models must fail open to image input" ); let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read Codex config.toml"); assert!(live_config.contains("model_catalog_json")); updated.settings_config["modelCatalog"] = json!({ "models": [] }); ProviderService::update(&state, AppType::Codex, None, updated) .expect("remove current Codex provider mapping"); let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path()) .expect("read Codex config.toml after mapping removal"); assert!( !live_config.contains("model_catalog_json"), "removing mappings during takeover must clear the stale catalog pointer" ); state .proxy_service .stop() .await .expect("stop proxy service"); } #[cfg(any(target_os = "macos", windows))] #[tokio::test] #[serial] async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() { let home = TempHome::new(); crate::settings::reload_settings().expect("reload settings"); let db = Arc::new(Database::memory().expect("init db")); let state = AppState::new(db.clone()); let mut original = Provider::with_id( "p1".into(), "Desktop A".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token-a", "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" } }), None, ); original.meta = Some(ProviderMeta { api_format: Some("openai_chat".into()), claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".into(), ClaudeDesktopModelRoute { model: "deepseek-v4-flash".into(), label_override: Some("DeepSeek V4 Flash".into()), supports_1m: None, }, )]), ..Default::default() }); db.save_provider("claude-desktop", &original) .expect("save provider"); db.set_current_provider("claude-desktop", "p1") .expect("set current provider"); crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some("p1")) .expect("set local current provider"); // Claude Desktop keeps backup state from takeover startup; this sentinel only // marks takeover as active so provider updates rewrite the 3P profile. db.save_live_backup("claude-desktop", "{}") .await .expect("seed live backup"); { let mut config = db .get_proxy_config_for_app("claude-desktop") .await .expect("get app proxy config"); config.enabled = true; db.update_proxy_config_for_app(config) .await .expect("update app proxy config"); } state .proxy_service .start() .await .expect("start proxy service"); let mut updated = Provider::with_id( "p1".into(), "Desktop A".into(), json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token-updated", "ANTHROPIC_BASE_URL": "https://opencode.ai/zen/go" } }), None, ); updated.meta = Some(ProviderMeta { api_format: Some("openai_chat".into()), claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), claude_desktop_model_routes: std::collections::HashMap::from([( "claude-sonnet-4-6".into(), ClaudeDesktopModelRoute { model: "deepseek-v4-flash".into(), label_override: Some("DeepSeek V4 Flash Updated".into()), supports_1m: Some(true), }, )]), ..Default::default() }); ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone()) .expect("update current provider"); let backup = db .get_live_backup("claude-desktop") .await .expect("get live backup") .expect("backup exists"); assert_eq!( backup.original_config, "{}", "Claude Desktop provider edits should not rewrite takeover backup" ); let profile_path = claude_desktop_profile_path(home.dir.path()); let profile: Value = read_json_file(&profile_path).expect("read desktop profile"); assert_eq!( profile["inferenceGatewayBaseUrl"], json!("http://127.0.0.1:15721/claude-desktop"), "desktop profile should stay pointed at the local gateway during takeover" ); assert_eq!(profile["inferenceGatewayAuthScheme"], json!("bearer")); assert_eq!( profile["inferenceModels"], json!([{ "name": "claude-sonnet-4-6", "labelOverride": "DeepSeek V4 Flash Updated", "supports1m": true }]), "provider edits should propagate into the Claude Desktop 3P profile during takeover" ); } #[test] #[serial] fn rename_rejects_missing_original_provider() { with_test_home(|state, _| { let original = openclaw_provider("deepseek"); ProviderService::add(state, AppType::OpenClaw, original.clone(), false) .expect("seed db-only provider"); let mut renamed = original.clone(); renamed.id = "deepseek-copy".to_string(); let err = ProviderService::update( state, AppType::OpenClaw, Some("missing-provider"), renamed, ) .expect_err("stale originalId should be rejected"); assert!( err.to_string().contains("Original provider"), "expected missing original provider error, got {err:?}" ); assert!( state .db .get_provider_by_id("deepseek-copy", AppType::OpenClaw.as_str()) .expect("query renamed provider") .is_none(), "rename must not create a new row when originalId is stale" ); }); } #[test] #[serial] fn db_only_additive_update_survives_live_config_parse_errors() { with_test_home(|state, home| { let provider = openclaw_provider("deepseek"); ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) .expect("seed db-only provider"); let stored = state .db .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) .expect("query stored provider") .expect("provider should exist"); assert_eq!( stored .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(false), "db-only provider should be marked as not live-managed" ); let openclaw_dir = home.join(".openclaw"); fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") .expect("write malformed config"); let mut updated = stored.clone(); updated.name = "DeepSeek Edited".to_string(); updated.meta.get_or_insert_with(ProviderMeta::default); ProviderService::update(state, AppType::OpenClaw, None, updated) .expect("db-only update should ignore live parse errors"); let saved = state .db .get_provider_by_id("deepseek", AppType::OpenClaw.as_str()) .expect("query updated provider") .expect("updated provider should exist"); assert_eq!(saved.name, "DeepSeek Edited"); }); } #[test] #[serial] fn sync_current_provider_for_app_skips_db_only_opencode_provider() { with_test_home(|state, _| { let provider = opencode_provider("db-only-opencode"); ProviderService::add(state, AppType::OpenCode, provider.clone(), false) .expect("seed db-only opencode provider"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync additive opencode providers"); let live_providers = crate::opencode_config::get_providers() .expect("read opencode providers after sync"); assert!( !live_providers.contains_key(&provider.id), "db-only opencode provider should not be written to live during sync" ); }); } #[test] #[serial] fn sync_current_provider_for_app_skips_db_only_openclaw_provider() { with_test_home(|state, _| { let provider = openclaw_provider("db-only-openclaw"); ProviderService::add(state, AppType::OpenClaw, provider.clone(), false) .expect("seed db-only openclaw provider"); ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) .expect("sync additive openclaw providers"); let live_providers = crate::openclaw_config::get_providers() .expect("read openclaw providers after sync"); assert!( !live_providers.contains_key(&provider.id), "db-only openclaw provider should not be written to live during sync" ); }); } #[test] #[serial] fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() { with_test_home(|state, _| { let provider = opencode_provider("legacy-opencode"); crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed opencode live provider"); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .expect("seed legacy opencode provider in db"); let mut updated = provider.clone(); updated.settings_config["options"]["apiKey"] = Value::String("updated-key".to_string()); state .db .save_provider(AppType::OpenCode.as_str(), &updated) .expect("update legacy opencode provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync legacy opencode provider"); let live_providers = crate::opencode_config::get_providers().expect("read opencode providers"); assert_eq!( live_providers .get(&provider.id) .and_then(|config| config.get("options")) .and_then(|options| options.get("apiKey")), Some(&Value::String("updated-key".to_string())), "legacy provider that already exists in live should still be synced" ); }); } #[test] #[serial] fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() { with_test_home(|state, _| { let provider = opencode_provider("legacy-opencode-reset"); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .expect("seed legacy opencode provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenCode) .expect("sync legacy opencode provider after reset"); let live_providers = crate::opencode_config::get_providers().expect("read opencode providers"); assert!( live_providers.contains_key(&provider.id), "legacy opencode provider should be restored when live config is reset" ); }); } #[test] #[serial] fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() { with_test_home(|state, _| { let mut provider = openclaw_provider("legacy-openclaw-reset"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); state .db .save_provider(AppType::OpenClaw.as_str(), &provider) .expect("seed legacy openclaw provider in db"); ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw) .expect("sync legacy openclaw provider after reset"); let live_providers = crate::openclaw_config::get_providers().expect("read openclaw providers"); assert!( live_providers.contains_key(&provider.id), "legacy openclaw provider should be restored when live config is reset" ); }); } #[test] #[serial] fn switch_from_managed_codex_official_to_unbound_clears_live_without_backfilling_token() { with_test_home(|state, _| { crate::settings::reload_settings().expect("reload settings"); tauri::async_runtime::block_on(async { state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed", "managed-token", Some("managed-id-token"), ) .await .expect("seed managed Codex OAuth account"); }); let mut managed = Provider::with_id( "managed-official".to_string(), "Managed Official".to_string(), json!({ "auth": {}, "config": "" }), None, ); managed.category = Some("official".to_string()); managed.meta = Some(ProviderMeta { auth_binding: Some(AuthBinding { source: AuthBindingSource::ManagedAccount, auth_provider: Some("codex_oauth".to_string()), account_id: Some("acct-managed".to_string()), }), ..Default::default() }); let mut unbound = Provider::with_id( "unbound-official".to_string(), "Unbound Official".to_string(), json!({ "auth": {}, "config": "" }), None, ); unbound.category = Some("official".to_string()); state .db .save_provider(AppType::Codex.as_str(), &managed) .expect("save managed provider"); state .db .save_provider(AppType::Codex.as_str(), &unbound) .expect("save unbound provider"); ProviderService::switch(state, AppType::Codex, "managed-official") .expect("switch to managed official"); let live_auth: Value = read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read managed live auth"); assert_eq!( live_auth .pointer("/tokens/access_token") .and_then(Value::as_str), Some("managed-token"), "managed switch should write the selected ChatGPT token to live auth" ); ProviderService::switch(state, AppType::Codex, "unbound-official") .expect("switch to unbound official"); assert!( !crate::codex_config::get_codex_auth_path().exists(), "switching to an unbound official provider should clear the recorded managed live auth" ); let saved_managed = state .db .get_provider_by_id("managed-official", AppType::Codex.as_str()) .expect("query managed provider") .expect("managed provider should exist"); assert_eq!( saved_managed.settings_config.get("auth"), Some(&json!({})), "switch-away backfill must not persist the managed access token into provider storage" ); }); } #[test] #[serial] fn managed_codex_switch_db_current_failure_restores_live_bundle_and_current() { with_test_home(|state, _| { crate::settings::reload_settings().expect("reload settings"); tauri::async_runtime::block_on(async { state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-a", "managed-token-a", Some("managed-id-token-a"), ) .await .expect("seed first managed Codex OAuth account"); state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-b", "managed-token-b", Some("managed-id-token-b"), ) .await .expect("seed second managed Codex OAuth account"); }); let managed_provider = |id: &str, account_id: &str, model: &str| { let mut provider = Provider::with_id( id.to_string(), format!("Managed {id}"), json!({ "auth": {}, "config": format!("model = \"{model}\"\n"), "modelCatalog": { "models": [{ "model": model }] } }), None, ); provider.category = Some("official".to_string()); provider.meta = Some(ProviderMeta { auth_binding: Some(AuthBinding { source: AuthBindingSource::ManagedAccount, auth_provider: Some("codex_oauth".to_string()), account_id: Some(account_id.to_string()), }), ..Default::default() }); provider }; let provider_a = managed_provider("managed-a", "acct-managed-a", "gpt-5.4-managed-a"); let provider_b = managed_provider("managed-b", "acct-managed-b", "gpt-5.4-managed-b"); state .db .save_provider(AppType::Codex.as_str(), &provider_a) .expect("save first managed provider"); state .db .save_provider(AppType::Codex.as_str(), &provider_b) .expect("save second managed provider"); ProviderService::switch(state, AppType::Codex, &provider_a.id) .expect("activate first managed provider"); let auth_before: Value = read_json_file(&crate::codex_config::get_codex_auth_path()) .expect("read first managed auth"); assert!( crate::codex_config::get_codex_config_path().exists(), "baseline must include config.toml" ); assert!( crate::codex_config::get_codex_model_catalog_path().exists(), "baseline must include the generated model catalog" ); assert!( crate::codex_config::codex_auth_matches_recorded_managed_oauth( &auth_before, "acct-managed-a", ) .expect("check first managed auth marker"), "baseline must include a marker owned by the first managed account" ); let live_before = crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture auth/config/catalog/marker before failed switch"); { let conn = state.db.conn.lock().expect("lock database"); conn.execute_batch( "CREATE TRIGGER reject_managed_b_current_update BEFORE UPDATE OF is_current ON providers WHEN NEW.app_type = 'codex' AND NEW.id = 'managed-b' AND NEW.is_current = 1 BEGIN SELECT RAISE(ABORT, 'forced managed Codex current failure'); END;", ) .expect("install current-provider failure trigger"); } let error = ProviderService::switch(state, AppType::Codex, &provider_b.id) .expect_err("DB current failure should abort managed switch"); assert!( error .to_string() .contains("forced managed Codex current failure"), "switch should surface the DB commit failure, got: {error}" ); let live_after = crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture auth/config/catalog/marker after rollback"); assert_eq!( live_after, live_before, "failed switch must exactly restore auth, config, catalog, and managed marker" ); assert_eq!( crate::settings::get_current_provider(&AppType::Codex).as_deref(), Some(provider_a.id.as_str()), "failed switch must restore the device-local current provider" ); assert_eq!( state .db .get_current_provider(AppType::Codex.as_str()) .expect("read DB current after rollback") .as_deref(), Some(provider_a.id.as_str()), "failed switch must keep the DB current provider unchanged" ); }); } #[test] #[serial] fn managed_codex_takeover_update_db_failure_restores_backup_live_and_binding() { with_test_home(|state, _| { crate::settings::reload_settings().expect("reload settings"); tauri::async_runtime::block_on(async { state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-a", "managed-token-a", Some("managed-id-a"), ) .await .expect("seed managed account A"); state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-b", "managed-token-b", Some("managed-id-b"), ) .await .expect("seed managed account B"); }); let mut provider = Provider::with_id( crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), "OpenAI Official A".to_string(), json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), None, ); provider.category = Some("official".to_string()); provider.meta = Some(ProviderMeta { auth_binding: Some(AuthBinding { source: AuthBindingSource::ManagedAccount, auth_provider: Some("codex_oauth".to_string()), account_id: Some("acct-managed-a".to_string()), }), ..Default::default() }); state .db .save_provider(AppType::Codex.as_str(), &provider) .expect("save official provider A"); state .db .set_current_provider(AppType::Codex.as_str(), &provider.id) .expect("set DB current"); crate::settings::set_current_provider(&AppType::Codex, Some(&provider.id)) .expect("set local current"); tauri::async_runtime::block_on(async { state .db .update_proxy_config(ProxyConfig { listen_port: 15_721, ..Default::default() }) .await .expect("set proxy port"); state .db .save_live_backup( AppType::Codex.as_str(), &serde_json::to_string(&json!({ "config": "model = \"gpt-5.4\"\n" })) .expect("serialize baseline backup"), ) .await .expect("save baseline backup"); state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&provider) .await .expect("seed managed takeover live"); }); let backup_before = tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str())) .expect("read baseline backup") .expect("baseline backup exists"); let live_before = crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture managed takeover live"); { let conn = state.db.conn.lock().expect("lock database"); conn.execute_batch( "CREATE TRIGGER reject_managed_takeover_provider_update BEFORE UPDATE ON providers WHEN NEW.app_type = 'codex' AND NEW.id = 'codex-official' AND NEW.name = 'OpenAI Official B' BEGIN SELECT RAISE(ABORT, 'forced managed takeover provider failure'); END;", ) .expect("install provider failure trigger"); } let mut updated = provider.clone(); updated.name = "OpenAI Official B".to_string(); updated .meta .as_mut() .and_then(|meta| meta.auth_binding.as_mut()) .expect("managed binding") .account_id = Some("acct-managed-b".to_string()); let error = ProviderService::update(state, AppType::Codex, None, updated) .expect_err("DB failure should abort takeover update"); assert!( error .to_string() .contains("forced managed takeover provider failure"), "update should surface DB failure: {error}" ); let saved = state .db .get_provider_by_id(&provider.id, AppType::Codex.as_str()) .expect("read provider after rollback") .expect("provider still exists"); assert_eq!(saved.name, "OpenAI Official A"); assert_eq!( saved .meta .as_ref() .and_then(|meta| meta.managed_account_id_for("codex_oauth")), Some("acct-managed-a".to_string()) ); let backup_after = tauri::async_runtime::block_on(state.db.get_live_backup(AppType::Codex.as_str())) .expect("read backup after rollback") .expect("backup still exists"); assert_eq!(backup_after.original_config, backup_before.original_config); assert_eq!( crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture live after rollback"), live_before, "failed takeover update must restore auth/config/catalog/marker exactly" ); }); } #[test] #[serial] fn managed_codex_update_rechecks_current_after_waiting_for_switch_lock() { with_test_home(|state, _| { crate::settings::reload_settings().expect("reload settings"); tauri::async_runtime::block_on(async { state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-a", "managed-token-a", Some("managed-id-a"), ) .await .expect("seed managed account A"); state .codex_oauth_manager .add_test_account_with_access_token( "acct-managed-b", "managed-token-b", Some("managed-id-b"), ) .await .expect("seed managed account B"); }); let mut official = Provider::with_id( crate::database::CODEX_OFFICIAL_PROVIDER_ID.to_string(), "OpenAI Official".to_string(), json!({ "auth": {}, "config": "model = \"gpt-5.4\"\n" }), None, ); official.category = Some("official".to_string()); official.meta = Some(ProviderMeta { auth_binding: Some(AuthBinding { source: AuthBindingSource::ManagedAccount, auth_provider: Some("codex_oauth".to_string()), account_id: Some("acct-managed-a".to_string()), }), ..Default::default() }); state .db .save_provider(AppType::Codex.as_str(), &official) .expect("save official A"); state .db .set_current_provider(AppType::Codex.as_str(), &official.id) .expect("set official current"); crate::settings::set_current_provider(&AppType::Codex, Some(&official.id)) .expect("set local official current"); let mut third_party = Provider::with_id( "third-party-current".to_string(), "Third Party".to_string(), json!({ "auth": { "OPENAI_API_KEY": "sk-third" }, "config": r#"model_provider = "third" [model_providers.third] name = "Third" base_url = "https://third.example/v1" wire_api = "responses" "# }), None, ); third_party.category = Some("custom".to_string()); state .db .save_provider(AppType::Codex.as_str(), &third_party) .expect("save third party"); let mut updated = official.clone(); updated .meta .as_mut() .and_then(|meta| meta.auth_binding.as_mut()) .expect("managed binding") .account_id = Some("acct-managed-b".to_string()); let switch_guard = tauri::async_runtime::block_on( state .proxy_service .lock_switch_for_app(AppType::Codex.as_str()), ); let (started_tx, started_rx) = std::sync::mpsc::channel(); let (update_result, live_after_switch) = std::thread::scope(|scope| { let updater = scope.spawn(move || { started_tx.send(()).expect("signal updater start"); ProviderService::update(state, AppType::Codex, None, updated) }); started_rx.recv().expect("wait for updater"); // This emulates a switch that already owns the per-app lock and // commits a different current target before the queued update is // allowed to inspect current/existing state. state .db .set_current_provider(AppType::Codex.as_str(), &third_party.id) .expect("switch DB current to third party"); crate::settings::set_current_provider( &AppType::Codex, Some(third_party.id.as_str()), ) .expect("switch local current to third party"); write_live_with_common_config_for_state(state, &AppType::Codex, &third_party) .expect("write third-party live"); let live_after_switch = crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture third-party live"); drop(switch_guard); let result = updater.join().expect("join managed updater"); (result, live_after_switch) }); update_result.expect("save queued non-current managed row"); assert_eq!( state .db .get_current_provider(AppType::Codex.as_str()) .expect("read DB current") .as_deref(), Some(third_party.id.as_str()) ); assert_eq!( crate::codex_config::CodexLiveStateSnapshot::capture() .expect("capture live after queued update"), live_after_switch, "queued provider edit must not rewrite the newly switched current live" ); let saved_official = state .db .get_provider_by_id(&official.id, AppType::Codex.as_str()) .expect("read saved official") .expect("official exists"); assert_eq!( saved_official .meta .as_ref() .and_then(|meta| meta.managed_account_id_for("codex_oauth")), Some("acct-managed-b".to_string()) ); }); } #[test] #[serial] fn switch_to_managed_codex_official_with_unresolvable_account_keeps_current_unchanged() { with_test_home(|state, _| { crate::settings::reload_settings().expect("reload settings"); // 基线:一个普通第三方 provider,可正常切换,作为初始 current。 let mut baseline = Provider::with_id( "baseline".to_string(), "Baseline".to_string(), json!({ "auth": { "OPENAI_API_KEY": "sk-baseline" }, "config": "" }), None, ); baseline.category = Some("custom".to_string()); // 托管 official provider,绑定一个 manager 中不存在的账号:切换预检 // 取 token 必然失败。 let mut managed = Provider::with_id( "managed-official".to_string(), "Managed Official".to_string(), json!({ "auth": {}, "config": "" }), None, ); managed.category = Some("official".to_string()); managed.meta = Some(ProviderMeta { auth_binding: Some(AuthBinding { source: AuthBindingSource::ManagedAccount, auth_provider: Some("codex_oauth".to_string()), account_id: Some("acct-missing".to_string()), }), ..Default::default() }); state .db .save_provider(AppType::Codex.as_str(), &baseline) .expect("save baseline"); state .db .save_provider(AppType::Codex.as_str(), &managed) .expect("save managed"); ProviderService::switch(state, AppType::Codex, "baseline").expect("switch to baseline"); // 切到绑定了不存在账号的托管 provider:预检失败 → 返回 Err。 let result = ProviderService::switch(state, AppType::Codex, "managed-official"); assert!( result.is_err(), "switch must fail when the managed OAuth token cannot be resolved" ); // current 必须仍是 baseline:预检在提交 current 之前失败,不留下 // 「DB/UI 指向新 provider、但 live 仍是旧 provider」的不一致状态。 let current = crate::settings::get_effective_current_provider(&state.db, &AppType::Codex) .expect("read current"); assert_eq!( current.as_deref(), Some("baseline"), "a failed managed switch must not move current off the previous provider" ); }); } #[test] #[serial] fn import_opencode_providers_from_live_marks_provider_as_live_managed() { with_test_home(|state, _| { let provider = opencode_provider("imported-opencode"); crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed opencode live provider"); let imported = import_opencode_providers_from_live(state) .expect("import opencode providers from live"); assert_eq!(imported, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .expect("query imported opencode provider") .expect("imported opencode provider should exist"); assert_eq!( saved .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(true), "providers imported from live should be treated as live-managed" ); }); } #[test] #[serial] fn import_opencode_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let provider = opencode_provider("existing-opencode"); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .expect("seed existing opencode provider"); let mut live_settings = provider.settings_config.clone(); live_settings.as_object_mut().unwrap().remove("name"); live_settings["npm"] = Value::String("@ai-sdk/anthropic".to_string()); live_settings["models"]["gpt-4o"]["name"] = Value::String("Claude Sonnet".to_string()); crate::opencode_config::set_provider(&provider.id, live_settings) .expect("seed edited live opencode provider"); let updated = import_opencode_providers_from_live(state) .expect("import opencode providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .expect("query updated opencode provider") .expect("opencode provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!(saved.settings_config["npm"], json!("@ai-sdk/anthropic")); assert_eq!( saved.settings_config["models"]["gpt-4o"]["name"], json!("Claude Sonnet") ); }); } #[test] #[serial] fn import_openclaw_providers_from_live_marks_provider_as_live_managed() { with_test_home(|state, _| { let mut provider = openclaw_provider("imported-openclaw"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone()) .expect("seed openclaw live provider"); let imported = import_openclaw_providers_from_live(state) .expect("import openclaw providers from live"); assert_eq!(imported, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) .expect("query imported openclaw provider") .expect("imported openclaw provider should exist"); assert_eq!( saved .meta .as_ref() .and_then(|meta| meta.live_config_managed), Some(true), "providers imported from live should be treated as live-managed" ); }); } #[test] #[serial] fn import_openclaw_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let mut provider = openclaw_provider("existing-openclaw"); provider.settings_config["models"] = json!([ { "id": "claude-sonnet-4", "name": "Claude Sonnet 4" } ]); state .db .save_provider(AppType::OpenClaw.as_str(), &provider) .expect("seed existing openclaw provider"); let mut live_settings = provider.settings_config.clone(); live_settings["baseUrl"] = Value::String("https://api.example.com/v1".to_string()); live_settings["models"][0]["name"] = Value::String("Claude Sonnet 4.1".to_string()); crate::openclaw_config::set_provider(&provider.id, live_settings) .expect("seed edited live openclaw provider"); let updated = import_openclaw_providers_from_live(state) .expect("import openclaw providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str()) .expect("query updated openclaw provider") .expect("openclaw provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["baseUrl"], json!("https://api.example.com/v1") ); assert_eq!( saved.settings_config["models"][0]["name"], json!("Claude Sonnet 4.1") ); }); } #[test] #[serial] fn import_hermes_providers_from_live_updates_existing_provider_from_live() { with_test_home(|state, _| { let provider = hermes_provider("existing-hermes"); state .db .save_provider(AppType::Hermes.as_str(), &provider) .expect("seed existing hermes provider"); let mut live_settings = provider.settings_config.clone(); live_settings["base_url"] = Value::String("https://api.hermes.example/v1".to_string()); live_settings["models"]["gpt-4o"]["name"] = Value::String("GPT-4o Updated".to_string()); crate::hermes_config::set_provider(&provider.id, live_settings) .expect("seed edited live hermes provider"); let updated = import_hermes_providers_from_live(state) .expect("import hermes providers from live"); assert_eq!(updated, 1); let saved = state .db .get_provider_by_id(&provider.id, AppType::Hermes.as_str()) .expect("query updated hermes provider") .expect("hermes provider should exist"); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["base_url"], json!("https://api.hermes.example/v1") ); // models are denormalized from YAML dict to UI-friendly array by // get_providers(), so access by index rather than dict key assert_eq!( saved.settings_config["models"][0]["name"], json!("GPT-4o Updated") ); assert_eq!(saved.settings_config["models"][0]["id"], json!("gpt-4o")); }); } #[test] #[serial] fn legacy_additive_provider_still_errors_on_live_config_parse_failure() { with_test_home(|state, home| { let provider = openclaw_provider("legacy-provider"); state .db .save_provider(AppType::OpenClaw.as_str(), &provider) .expect("seed legacy provider without live_config_managed marker"); let openclaw_dir = home.join(".openclaw"); fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); fs::write(openclaw_dir.join("openclaw.json"), "{ invalid json5") .expect("write malformed config"); let mut updated = provider.clone(); updated.name = "Legacy Edited".to_string(); let err = ProviderService::update(state, AppType::OpenClaw, None, updated) .expect_err("legacy providers should still surface live parse errors"); assert!( err.to_string().contains("Failed to parse OpenClaw config"), "expected parse error, got {err:?}" ); }); } #[test] #[serial] fn update_persists_non_current_omo_variants_in_database() { with_test_home(|state, _| { for category in ["omo", "omo-slim"] { let provider = opencode_omo_provider(&format!("{category}-provider"), category); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed {category} provider: {err}")); let mut updated = provider.clone(); updated.name = format!("Updated {category}"); updated.settings_config["agents"]["writer"]["model"] = Value::String(format!("{category}-next-model")); ProviderService::update(state, AppType::OpenCode, None, updated) .unwrap_or_else(|err| panic!("update {category} provider: {err}")); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query updated {category} provider: {err}")) .unwrap_or_else(|| panic!("{category} provider should exist")); assert_eq!(saved.name, format!("Updated {category}")); assert_eq!( saved.settings_config["agents"]["writer"]["model"], Value::String(format!("{category}-next-model")), "{category} updates should persist in the database" ); } }); } #[test] #[serial] fn update_current_omo_variant_rewrites_config_from_saved_provider() { with_test_home(|state, home| { for category in ["omo", "omo-slim"] { let provider = opencode_omo_provider(&format!("{category}-current"), category); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current {category} provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category) .unwrap_or_else(|err| panic!("set current {category} provider: {err}")); let mut updated = provider.clone(); updated.name = format!("Current {category} updated"); updated.settings_config["agents"]["writer"]["model"] = Value::String(format!("{category}-saved-model")); updated.settings_config["otherFields"]["theme"] = Value::String(format!("{category}-light")); ProviderService::update(state, AppType::OpenCode, None, updated) .unwrap_or_else(|err| panic!("update current {category} provider: {err}")); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current {category} provider: {err}")) .unwrap_or_else(|| panic!("current {category} provider should exist")); assert_eq!(saved.name, format!("Current {category} updated")); let written = fs::read_to_string(omo_config_path(home, category)) .unwrap_or_else(|err| panic!("read written {category} config: {err}")); let written_json: Value = serde_json::from_str(&written) .unwrap_or_else(|err| panic!("parse written {category} config: {err}")); assert_eq!( written_json["agents"]["writer"]["model"], Value::String(format!("{category}-saved-model")), "{category} config should be written from the saved provider state" ); assert_eq!( written_json["theme"], Value::String(format!("{category}-light")), "{category} top-level config should reflect updated otherFields" ); } }); } #[test] #[serial] fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() { with_test_home(|state, home| { let provider = opencode_omo_provider("omo-current", "omo"); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") .unwrap_or_else(|err| panic!("set current omo provider: {err}")); let config_dir = home.join(".config").join("opencode"); fs::create_dir_all(config_dir.parent().expect("config dir parent")) .expect("create .config dir"); fs::write(&config_dir, "not a directory").expect("block opencode config dir"); let mut updated = provider.clone(); updated.name = "Current omo updated".to_string(); updated.settings_config["agents"]["writer"]["model"] = Value::String("omo-saved-model".to_string()); ProviderService::update(state, AppType::OpenCode, None, updated) .expect_err("update should fail when current omo file write fails"); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current omo provider: {err}")) .unwrap_or_else(|| panic!("current omo provider should exist")); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["agents"]["writer"]["model"], provider.settings_config["agents"]["writer"]["model"], "database should remain unchanged when file write fails" ); }); } #[test] #[serial] fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() { with_test_home(|state, home| { let provider = opencode_omo_provider("omo-current", "omo"); state .db .save_provider(AppType::OpenCode.as_str(), &provider) .unwrap_or_else(|err| panic!("seed current omo provider: {err}")); state .db .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, "omo") .unwrap_or_else(|err| panic!("set current omo provider: {err}")); let config_path = omo_config_path(home, "omo"); fs::create_dir_all(config_path.parent().expect("omo config parent")) .expect("create omo config dir"); let previous_content = serde_json::to_string_pretty(&json!({ "theme": "legacy-live-theme", "agents": { "writer": { "model": "legacy-live-model" } }, "categories": { "default": ["writer"] } })) .expect("serialize previous config"); fs::write(&config_path, &previous_content).expect("seed previous omo config"); let opencode_config_path = home.join(".config").join("opencode").join("opencode.json"); fs::write(&opencode_config_path, "{ invalid json").expect("seed malformed opencode"); let mut updated = provider.clone(); updated.name = "Current omo updated".to_string(); updated.settings_config["agents"]["writer"]["model"] = Value::String("omo-saved-model".to_string()); updated.settings_config["otherFields"]["theme"] = Value::String("omo-light".to_string()); ProviderService::update(state, AppType::OpenCode, None, updated) .expect_err("update should fail when plugin sync fails"); let saved = state .db .get_provider_by_id(&provider.id, AppType::OpenCode.as_str()) .unwrap_or_else(|err| panic!("query current omo provider: {err}")) .unwrap_or_else(|| panic!("current omo provider should exist")); assert_eq!(saved.name, provider.name); assert_eq!( saved.settings_config["agents"]["writer"]["model"], provider.settings_config["agents"]["writer"]["model"], "database should remain unchanged when plugin sync fails" ); let written = fs::read_to_string(&config_path).expect("read rolled back omo config content"); assert_eq!( written, previous_content, "OMO config should roll back to its previous on-disk contents" ); }); } } impl ProviderService { fn managed_codex_oauth_account_id(provider: &Provider) -> Option { provider .meta .as_ref() .and_then(|meta| meta.managed_account_id_for("codex_oauth")) .map(|id| id.trim().to_string()) .filter(|id| !id.is_empty()) } /// 提交 current(settings/DB)前的预检:若目标是托管 Codex official provider, /// 先解析一次有效 live 配置(会联网换取并缓存 token)。同时返回这份已解析配置, /// 让后续落盘直接复用同一 token bundle,避免一次操作重复解析/刷新。 fn preflight_managed_codex_live( state: &AppState, app_type: &AppType, provider: &Provider, ) -> Result, AppError> { if matches!(app_type, AppType::Codex) && Self::managed_codex_oauth_account_id(provider).is_some() { return build_effective_provider_for_live_with_codex_oauth_manager( state.db.as_ref(), app_type, provider, &state.codex_oauth_manager, ) .map(Some); } Ok(None) } fn write_preflighted_or_current_live( state: &AppState, app_type: &AppType, provider: &Provider, preflighted_provider: Option<&Provider>, ) -> Result<(), AppError> { if let Some(effective_provider) = preflighted_provider { live::write_live_snapshot(app_type, effective_provider) } else { write_live_with_common_config_for_state(state, app_type, provider) } } fn managed_codex_transaction_error( operation: &str, error: AppError, snapshot: &crate::codex_config::CodexLiveStateSnapshot, restore_local_current: Option<(&AppType, Option<&str>)>, ) -> AppError { let mut rollback_failures = Vec::new(); if let Some((app_type, previous_local_current)) = restore_local_current { if let Err(rollback_error) = crate::settings::set_current_provider(app_type, previous_local_current) { rollback_failures.push(format!("恢复本地 current 失败: {rollback_error}")); } } if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() { rollback_failures.push(rollback_error.to_string()); } if rollback_failures.is_empty() { error } else { AppError::Message(format!( "{operation}失败: {error}; 回滚同时失败: {}", rollback_failures.join("; ") )) } } fn managed_codex_takeover_transaction_error( state: &AppState, operation: &str, error: AppError, snapshot: &crate::codex_config::CodexLiveStateSnapshot, previous_backup: Option<&crate::proxy::types::LiveBackup>, ) -> AppError { let mut rollback_failures = Vec::new(); let backup_restore = match previous_backup { Some(backup) => futures::executor::block_on( state .db .save_live_backup(AppType::Codex.as_str(), &backup.original_config), ), None => { futures::executor::block_on(state.db.delete_live_backup(AppType::Codex.as_str())) } }; if let Err(rollback_error) = backup_restore { rollback_failures.push(format!("恢复 Codex Live 备份失败: {rollback_error}")); } if let Err(rollback_error) = snapshot.restore_preserving_newer_same_account_auth() { rollback_failures.push(rollback_error.to_string()); } if rollback_failures.is_empty() { error } else { AppError::Message(format!( "{operation}失败: {error}; 回滚同时失败: {}", rollback_failures.join("; ") )) } } fn unbound_managed_codex_oauth_account_id( app_type: &AppType, existing_provider: Option<&Provider>, provider: &Provider, ) -> Option { if !matches!(app_type, AppType::Codex) || provider.category.as_deref() != Some("official") { return None; } let existing_provider = existing_provider .filter(|existing| existing.category.as_deref() == Some("official"))?; let old_account_id = Self::managed_codex_oauth_account_id(existing_provider)?; if Self::managed_codex_oauth_account_id(provider).is_some() { return None; } Some(old_account_id) } fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) { if matches!(app_type, AppType::Claude) { let mut v = provider.settings_config.clone(); if normalize_claude_models_in_value(&mut v) { provider.settings_config = v; } } } /// Check whether a provider exists in live config, tolerating parse errors /// only for providers that are explicitly marked as DB-only. fn check_live_config_exists( app_type: &AppType, provider_id: &str, live_config_managed: Option, ) -> Result { if live_config_managed == Some(false) { Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false)) } else { provider_exists_in_live_config(app_type, provider_id) } } fn provider_live_config_managed(provider: &Provider) -> Option { provider .meta .as_ref() .and_then(|meta| meta.live_config_managed) } fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) { provider .meta .get_or_insert_with(Default::default) .live_config_managed = Some(managed); } fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) { let current_credentials = provider.resolve_usage_credentials(app_type); let Some(usage_script) = provider .meta .as_mut() .and_then(|meta| meta.usage_script.as_mut()) else { return; }; if usage_script.template_type.as_deref() == Some("token_plan") { return; } if usage_script.api_key.as_deref().is_some_and(|api_key| { Self::should_clear_usage_api_key_override(api_key, ¤t_credentials) }) { usage_script.api_key = None; } if usage_script.base_url.as_deref().is_some_and(|base_url| { Self::should_clear_usage_base_url_override(base_url, ¤t_credentials) }) { usage_script.base_url = None; } } fn should_clear_usage_api_key_override( script_api_key: &str, current_credentials: &(String, String), ) -> bool { let candidate = script_api_key.trim(); if candidate.is_empty() { return true; } let matches_provider_key = |api_key: &str| { let api_key = api_key.trim(); !api_key.is_empty() && api_key == candidate }; matches_provider_key(¤t_credentials.1) } fn should_clear_usage_base_url_override( script_base_url: &str, current_credentials: &(String, String), ) -> bool { let candidate = Self::normalize_usage_base_url_for_compare(script_base_url); if candidate.is_empty() { return true; } let matches_provider_base_url = |base_url: &str| { let base_url = Self::normalize_usage_base_url_for_compare(base_url); !base_url.is_empty() && base_url == candidate }; matches_provider_base_url(¤t_credentials.0) } fn normalize_usage_base_url_for_compare(base_url: &str) -> String { base_url.trim().trim_end_matches('/').to_string() } /// List all providers for an app type pub fn list( state: &AppState, app_type: AppType, ) -> Result, AppError> { state.db.get_all_providers(app_type.as_str()) } /// Get current provider ID /// /// 使用有效的当前供应商 ID(验证过存在性)。 /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。 /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。 /// /// 对于累加模式应用(OpenCode, OpenClaw),不存在"当前供应商"概念,直接返回空字符串。 pub fn current(state: &AppState, app_type: AppType) -> Result { // Additive mode apps have no "current" provider concept if app_type.is_additive_mode() { return Ok(String::new()); } crate::settings::get_effective_current_provider(&state.db, &app_type) .map(|opt| opt.unwrap_or_default()) } /// Add a new provider pub fn add( state: &AppState, app_type: AppType, provider: Provider, add_to_live: bool, ) -> Result { let mut provider = provider; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if app_type.is_additive_mode() { Self::set_provider_live_config_managed(&mut provider, add_to_live); } // Save to database state.db.save_provider(app_type.as_str(), &provider)?; // Additive mode apps (OpenCode, OpenClaw): optionally write to live config. if app_type.is_additive_mode() { // OMO / OMO Slim providers use exclusive mode and write to dedicated config file. if matches!(app_type, AppType::OpenCode) && matches!(provider.category.as_deref(), Some("omo") | Some("omo-slim")) { // Do not auto-enable newly added OMO / OMO Slim providers. // Users must explicitly switch/apply an OMO provider to activate it. return Ok(true); } if !add_to_live { return Ok(true); } write_live_with_common_config_for_state(state, &app_type, &provider)?; return Ok(true); } // For other apps: Check if sync is needed (if this is current provider, or no current provider) let current = state.db.get_current_provider(app_type.as_str())?; if current.is_none() { // 预检托管 Codex token:失败则不将其设为 current(见 switch)。 let preflighted_provider = Self::preflight_managed_codex_live(state, &app_type, &provider)?; if preflighted_provider.is_some() { let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; if let Err(error) = Self::write_preflighted_or_current_live( state, &app_type, &provider, preflighted_provider.as_ref(), ) { return Err(Self::managed_codex_transaction_error( "写入首个托管 Codex provider", error, &snapshot, None, )); } if let Err(error) = state .db .set_current_provider(app_type.as_str(), &provider.id) { return Err(Self::managed_codex_transaction_error( "设置首个托管 Codex provider 为 current", error, &snapshot, None, )); } } else { // No current provider, set as current and sync. state .db .set_current_provider(app_type.as_str(), &provider.id)?; write_live_with_common_config_for_state(state, &app_type, &provider)?; } } Ok(true) } /// Update a provider pub fn update( state: &AppState, app_type: AppType, original_id: Option<&str>, provider: Provider, ) -> Result { let mut provider = provider; let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); let provider_id_changed = original_id != provider.id; // Serialize the read/decide/commit window for every Codex update. We do // not yet know whether the stored row is managed (the request may be an // unbind), so the existing row and effective current must both be read // only after this lock is held. Non-managed Codex updates release it // before entering the legacy path, whose proxy helpers take the lock // themselves. let codex_update_switch_guard = if matches!(app_type, AppType::Codex) { Some(futures::executor::block_on( state.proxy_service.lock_switch_for_app(app_type.as_str()), )) } else { None }; let existing_provider = state .db .get_provider_by_id(&original_id, app_type.as_str())?; // Normalize Claude model keys Self::normalize_provider_if_claude(&app_type, &mut provider); Self::validate_provider_settings(&app_type, &provider)?; normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?; Self::normalize_usage_script_credential_overrides(&app_type, &mut provider); if provider_id_changed { if !app_type.is_additive_mode() { return Err(AppError::Message( "Only additive-mode providers support changing provider key".to_string(), )); } let Some(existing_provider) = existing_provider else { return Err(AppError::Message(format!( "Original provider '{}' does not exist in app '{}'", original_id, app_type.as_str() ))); }; // OMO / OMO Slim providers are activated via a dedicated current-state mechanism // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config, // which only checks opencode.json. A rename would orphan that current-state marker // and silently break subsequent OMO file syncs. Block it unconditionally. if matches!(app_type, AppType::OpenCode) && matches!( existing_provider.category.as_deref(), Some("omo") | Some("omo-slim") ) { return Err(AppError::Message( "Provider key cannot be changed for OMO/OMO Slim providers".to_string(), )); } let original_in_live = Self::check_live_config_exists( &app_type, &original_id, Self::provider_live_config_managed(&existing_provider), )?; if original_in_live { return Err(AppError::Message( "Provider key cannot be changed after the provider has been added to the app config" .to_string(), )); } let next_id_in_live = Self::check_live_config_exists( &app_type, &provider.id, Self::provider_live_config_managed(&existing_provider), )?; if state .db .get_provider_by_id(&provider.id, app_type.as_str())? .is_some() || next_id_in_live { return Err(AppError::Message(format!( "Provider '{}' already exists in app '{}'", provider.id, app_type.as_str() ))); } Self::set_provider_live_config_managed(&mut provider, false); state.db.save_provider(app_type.as_str(), &provider)?; state.db.delete_provider(app_type.as_str(), &original_id)?; if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) { crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?; } return Ok(true); } // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider // already exists in live config. Editing a DB-only provider must not auto-add it. if app_type.is_additive_mode() { let omo_variant = if matches!(app_type, AppType::OpenCode) { match provider.category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, } } else { None }; if let Some(variant) = omo_variant { let is_current = state.db.is_omo_provider_current( app_type.as_str(), &provider.id, variant.category, )?; if is_current { crate::services::OmoService::write_provider_config_to_file(&provider, variant)?; } if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) { if is_current { if let Err(rollback_err) = crate::services::OmoService::write_config_to_file(state, variant) { log::warn!( "Failed to roll back {} config after DB save error: {}", variant.label, rollback_err ); } } return Err(err); } return Ok(true); } let live_config_managed = Self::check_live_config_exists( &app_type, &provider.id, Self::provider_live_config_managed(&provider).or_else(|| { existing_provider .as_ref() .and_then(Self::provider_live_config_managed) }), )?; Self::set_provider_live_config_managed(&mut provider, live_config_managed); // Save to database after live-config presence is resolved so parse errors // do not report failure after already mutating DB state. state.db.save_provider(app_type.as_str(), &provider)?; if !live_config_managed { return Ok(true); } write_live_with_common_config_for_state(state, &app_type, &provider)?; return Ok(true); } let unbound_codex_managed_account_id = Self::unbound_managed_codex_oauth_account_id( &app_type, existing_provider.as_ref(), &provider, ); // For other apps: Check if this is current provider (use effective current, not just DB) let effective_current = crate::settings::get_effective_current_provider(&state.db, &app_type)?; let is_current = effective_current.as_deref() == Some(provider.id.as_str()); let existing_managed_codex_account_id = existing_provider .as_ref() .and_then(Self::managed_codex_oauth_account_id); let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(&provider); let managed_codex_update = matches!(app_type, AppType::Codex) && (existing_managed_codex_account_id.is_some() || target_managed_codex_account_id.is_some()); if managed_codex_update { // A non-current managed row still commits under the same lock: once // the row is saved, a waiting switch observes the new binding. If we // released first, a switch could activate the old binding and leave // DB current/live inconsistent with the subsequent save. if !is_current { state.db.save_provider(app_type.as_str(), &provider)?; return Ok(true); } // The lock acquired before reading existing/current spans the // complete direct/takeover transaction. Backup update and takeover // Live sync therefore cannot expose a gap to concurrent hot-switch. let previous_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))?; let has_live_backup = previous_backup.is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); let preflighted_provider = Self::preflight_managed_codex_live(state, &app_type, &provider)?; // Capture after preflight: a legitimate refresh may have advanced // auth.json, and rollback must never restore the older generation. let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; if !has_live_backup && !live_taken_over { let commit_result = (|| { Self::write_preflighted_or_current_live( state, &app_type, &provider, preflighted_provider.as_ref(), )?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } state.db.save_provider(app_type.as_str(), &provider)?; Ok::<(), AppError>(()) })(); if let Err(error) = commit_result { return Err(Self::managed_codex_transaction_error( "更新托管 Codex provider", error, &snapshot, None, )); } if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { log::warn!( "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" ); } return Ok(true); } let commit_result = (|| { futures::executor::block_on( state.proxy_service.update_live_backup_from_provider_inner( app_type.as_str(), &provider, unbound_codex_managed_account_id.as_deref(), ), ) .map_err(|error| AppError::Message(format!("更新 Live 备份失败: {error}")))?; if live_taken_over { futures::executor::block_on( state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&provider), ) .map_err(|error| { AppError::Message(format!("同步 Codex Live 配置失败: {error}")) })?; } else { // A backup without a takeover marker is a recoverable // half-takeover state. Keep the actual Live bundle aligned // with the edited current provider as well as the backup. Self::write_preflighted_or_current_live( state, &app_type, &provider, preflighted_provider.as_ref(), )?; } if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } // DB is the final commit. Every fallible side effect above can be // restored exactly while the previous provider row is untouched. state.db.save_provider(app_type.as_str(), &provider)?; Ok::<(), AppError>(()) })(); if let Err(error) = commit_result { return Err(Self::managed_codex_takeover_transaction_error( state, "更新接管中的托管 Codex provider", error, &snapshot, previous_backup.as_ref(), )); } return Ok(true); } drop(codex_update_switch_guard); // Save to database state.db.save_provider(app_type.as_str(), &provider)?; if is_current { // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行: // - 不直接走普通 Live 写入逻辑 // - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置 let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); // Backup or live placeholders mean the live file is currently owned // by proxy takeover, including the short activation window before // proxy_config.enabled is committed. let should_sync_via_proxy = has_live_backup || live_taken_over; if should_sync_via_proxy { if matches!(app_type, AppType::ClaudeDesktop) { write_live_with_common_config_for_state(state, &app_type, &provider)?; } else { let update_backup_result = if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { futures::executor::block_on( state .proxy_service .update_live_backup_from_provider_clearing_codex_auth( app_type.as_str(), &provider, account_id, ), ) } else { futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(app_type.as_str(), &provider), ) }; update_backup_result .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } } if futures::executor::block_on(state.proxy_service.is_running()) { if matches!(app_type, AppType::Claude) { futures::executor::block_on( state .proxy_service .sync_claude_live_from_provider_while_proxy_active(&provider), ) .map_err(|e| { AppError::Message(format!("同步 Claude Live 配置失败: {e}")) })?; } else if live_taken_over && matches!(app_type, AppType::Codex) { // Codex model mappings are projected into a generated // model_catalog_json file. Refresh takeover-owned Live // immediately so adding/removing mappings cannot leave // the previous catalog pointer and capabilities active. futures::executor::block_on( state .proxy_service .sync_codex_live_from_provider_while_proxy_active(&provider), ) .map_err(|e| AppError::Message(format!("同步 Codex Live 配置失败: {e}")))?; } } } else { write_live_with_common_config_for_state(state, &app_type, &provider)?; if let Some(account_id) = unbound_codex_managed_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把 // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存 // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已 // 成功;投影失败降级为警告,避免制造"保存失败"假象(MCP // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { log::warn!( "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" ); } } } Ok(true) } /// Delete a provider /// /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // Additive mode apps - no current provider concept if app_type.is_additive_mode() { // Single DB read shared across all additive-mode sub-paths below. let existing = state.db.get_provider_by_id(id, app_type.as_str())?; if matches!(app_type, AppType::OpenCode) { let provider_category = existing.as_ref().and_then(|p| p.category.clone()); let omo_variant = match provider_category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, }; if let Some(variant) = omo_variant { let was_current = state.db.is_omo_provider_current( app_type.as_str(), id, variant.category, )?; state.db.delete_provider(app_type.as_str(), id)?; if was_current { crate::services::OmoService::delete_config_file(variant)?; } return Ok(()); } } // Non-OMO path for both OpenCode and OpenClaw: // remove from live first (atomicity), then DB. // // Use check_live_config_exists rather than trusting the flag alone: the flag // can be stale (Some(false) for a provider that was written to live before the // live_config_managed flip was introduced). check_live_config_exists reads the // actual file when the flag is Some(false), so it handles historical data correctly. let live_managed = existing .as_ref() .and_then(Self::provider_live_config_managed); if Self::check_live_config_exists(&app_type, id, live_managed)? { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, AppType::Hermes => remove_hermes_provider_from_live(id)?, _ => {} } } state.db.delete_provider(app_type.as_str(), id)?; return Ok(()); } // For other apps: Check both local settings and database let local_current = crate::settings::get_current_provider(&app_type); let db_current = state.db.get_current_provider(app_type.as_str())?; if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) { return Err(AppError::Message( "无法删除当前正在使用的供应商".to_string(), )); } state.db.delete_provider(app_type.as_str(), id) } /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw) /// /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config /// but keep it available for future use. pub fn remove_from_live_config( state: &AppState, app_type: AppType, id: &str, ) -> Result<(), AppError> { match app_type { AppType::OpenCode => { let provider_category = state .db .get_provider_by_id(id, app_type.as_str())? .and_then(|p| p.category); let omo_variant = match provider_category.as_deref() { Some("omo") => Some(&crate::services::omo::STANDARD), Some("omo-slim") => Some(&crate::services::omo::SLIM), _ => None, }; if let Some(variant) = omo_variant { state .db .clear_omo_provider_current(app_type.as_str(), id, variant.category)?; let still_has_current = state .db .get_current_omo_provider("opencode", variant.category)? .is_some(); if still_has_current { crate::services::OmoService::write_config_to_file(state, variant)?; } else { crate::services::OmoService::delete_config_file(variant)?; } } else { remove_opencode_provider_from_live(id)?; } } AppType::OpenClaw => { remove_openclaw_provider_from_live(id)?; } AppType::Hermes => { remove_hermes_provider_from_live(id)?; } _ => { return Err(AppError::Message(format!( "App {} does not support remove from live config", app_type.as_str() ))); } } if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? { Self::set_provider_live_config_managed(&mut provider, false); state.db.save_provider(app_type.as_str(), &provider)?; } Ok(()) } /// Switch to a provider /// /// Switch flow: /// 1. Validate target provider exists /// 2. Check if proxy takeover mode is active AND proxy server is running /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels /// 4. If normal mode: /// a. **Backfill mechanism**: Backfill current live config to current provider /// b. Update local settings current_provider_xxx (device-level) /// c. Update database is_current (as default for new devices) /// d. Write target provider config to live files /// e. Sync MCP configuration pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result { // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; let _provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; // OMO providers are switched through their own exclusive path. if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { return Self::switch_normal(state, app_type, id, &providers); } // OMO Slim providers are switched through their own exclusive path. if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo-slim") { return Self::switch_normal(state, app_type, id, &providers); } if matches!(app_type, AppType::ClaudeDesktop) { return Self::switch_normal(state, app_type, id, &providers); } // Provider switches and takeover toggles both mutate live config and the // restore backup. Serialize them per app, then decide from the locked // current state so a just-started takeover cannot be overwritten by a // normal live write. let _switch_guard = if matches!( app_type, AppType::Claude | AppType::Codex | AppType::Gemini | AppType::GrokBuild ) { Some(futures::executor::block_on( state.proxy_service.lock_switch_for_app(app_type.as_str()), )) } else { None }; // Backup or live placeholders mean the live file is owned by proxy // takeover, even if the proxy server is temporarily stopped or is in the // activation window before enabled=true is committed. let is_app_taken_over = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); let should_hot_switch = is_app_taken_over || live_taken_over; // Block switching to official providers when proxy takeover is active. // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans. if should_hot_switch && _provider.category.as_deref() == Some("official") && !official_provider_supports_proxy_takeover(&app_type, _provider) { return Err(AppError::localized( "switch.official_blocked_by_proxy", "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。", "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.", )); } if should_hot_switch { // Proxy takeover mode: hot-switch without restoring upstream Live config. // The proxy layer may still refresh proxy-safe Live fields so client labels // follow the selected provider while endpoints remain local. log::info!( "代理接管模式:热切换 {} 的目标供应商为 {}", app_type.as_str(), id ); futures::executor::block_on( state .proxy_service .hot_switch_provider_inner(app_type.as_str(), id), ) .map_err(|e| AppError::Message(format!("热切换失败: {e}")))?; // The proxy server will route requests to the new provider via is_current. // MCP sync is intentionally skipped while Live config is owned by takeover. return Ok(SwitchResult::default()); } // Normal mode: full switch with Live config write Self::switch_normal(state, app_type, id, &providers) } /// Normal switch flow (non-proxy mode) fn switch_normal( state: &AppState, app_type: AppType, id: &str, providers: &indexmap::IndexMap, ) -> Result { let provider = providers .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file. if matches!(app_type, AppType::OpenCode) { let omo_pair = match provider.category.as_deref() { Some("omo") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)), Some("omo-slim") => { Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD)) } _ => None, }; if let Some((enable, disable)) = omo_pair { state .db .set_omo_provider_current(app_type.as_str(), id, enable.category)?; crate::services::OmoService::write_config_to_file(state, enable)?; let _ = crate::services::OmoService::delete_config_file(disable); return Ok(SwitchResult::default()); } } let mut result = SwitchResult::default(); // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; let current_managed_codex_account_id = current_id .as_deref() .and_then(|current_id| providers.get(current_id)) .and_then(Self::managed_codex_oauth_account_id); if let Some(current_id) = current_id { if current_id != id { // Additive mode apps - all providers coexist in the same file, // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) if !app_type.is_additive_mode() { // Only backfill when switching to a different provider if let Ok(live_config) = read_live_settings(app_type.clone()) { if let Some(mut current_provider) = providers.get(¤t_id).cloned() { // 切走前先把 live 里的可共享改动(含用户直接在应用内 // 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。 // 详见 sync_common_config_snippet_from_live 的文档。 Self::sync_common_config_snippet_from_live( state, &app_type, ¤t_provider, &live_config, &mut result, ); current_provider.settings_config = strip_common_config_from_live_settings( state.db.as_ref(), &app_type, ¤t_provider, live_config, ); if let Err(e) = state.db.save_provider(app_type.as_str(), ¤t_provider) { log::warn!("Backfill failed: {e}"); result .warnings .push(format!("backfill_failed:{current_id}")); } } } } } } // 提交 current 前预检托管 Codex token(见 preflight_managed_codex_live)。 let preflighted_provider = Self::preflight_managed_codex_live(state, &app_type, provider)?; let target_managed_codex_account_id = Self::managed_codex_oauth_account_id(provider); let use_managed_codex_transaction = matches!(app_type, AppType::Codex) && (current_managed_codex_account_id.is_some() || target_managed_codex_account_id.is_some()); if use_managed_codex_transaction { // auth/config/catalog/marker form one logical live commit. Write them // before current, then restore the exact four-file snapshot on any // failure so native logins and CLI-rotated tokens are not reconstructed // from a stale provider row. let snapshot = crate::codex_config::CodexLiveStateSnapshot::capture()?; let live_result = (|| { Self::write_preflighted_or_current_live( state, &app_type, provider, preflighted_provider.as_ref(), )?; if target_managed_codex_account_id.is_none() { if let Some(account_id) = current_managed_codex_account_id.as_deref() { crate::codex_config::clear_codex_live_auth_for_managed_account(account_id)?; } } Ok::<(), AppError>(()) })(); if let Err(error) = live_result { return Err(Self::managed_codex_transaction_error( "写入 Codex Live", error, &snapshot, None, )); } let previous_local_current = crate::settings::get_current_provider(&app_type); if let Err(error) = crate::settings::set_current_provider(&app_type, Some(id)) { return Err(Self::managed_codex_transaction_error( "更新本地 current", error, &snapshot, Some((&app_type, previous_local_current.as_deref())), )); } if let Err(error) = state.db.set_current_provider(app_type.as_str(), id) { return Err(Self::managed_codex_transaction_error( "更新数据库 current", error, &snapshot, Some((&app_type, previous_local_current.as_deref())), )); } } else { // Additive mode apps skip setting is_current (no such concept). if !app_type.is_additive_mode() { crate::settings::set_current_provider(&app_type, Some(id))?; state.db.set_current_provider(app_type.as_str(), id)?; } // Sync to live (write_gemini_live handles security flag internally for Gemini). Self::write_preflighted_or_current_live( state, &app_type, provider, preflighted_provider.as_ref(), )?; } // Hermes is additive, so "switching" doesn't overwrite a live config file // — we instead update the top-level `model:` section to point at this // provider's first declared model. Without this, clicking "switch" would // only shuffle entries in custom_providers[] while Hermes keeps using // whatever `model.provider` was set before. if matches!(app_type, AppType::Hermes) { if let Err(e) = crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config) { log::warn!( "Failed to update Hermes model defaults after switching to '{}': {e}", provider.id ); result .warnings .push(format!("hermes_model_defaults_failed:{}", provider.id)); } } // For additive-mode providers that were DB-only (live_config_managed == Some(false)), // flip the flag to true now that the provider has been successfully written to the live // file. This ensures sync_all_providers_to_live() will include it on future syncs. // // If persisting the marker fails, roll back the just-written live config so we don't leave // the provider in a silent inconsistent state (present in live, but still marked DB-only). if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true) { let mut updated = provider.clone(); Self::set_provider_live_config_managed(&mut updated, true); if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), AppType::Hermes => remove_hermes_provider_from_live(&provider.id), _ => Ok(()), }; match rollback_result { Ok(()) => { return Err(AppError::Message(format!( "Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}", provider.id ))); } Err(rollback_err) => { return Err(AppError::Message(format!( "Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}", provider.id ))); } } } } // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的 // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的 // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled: // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。 // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功; // 投影失败上抛会让前端报"切换失败"制造分裂假象,故降级为警告 // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { log::warn!("切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"); } Ok(result) } /// Sync current provider to live configuration (re-export) pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { sync_current_to_live(state) } pub fn sync_current_provider_for_app( state: &AppState, app_type: AppType, ) -> Result<(), AppError> { if app_type.is_additive_mode() { return sync_current_provider_for_app_to_live(state, &app_type); } let current_id = match crate::settings::get_effective_current_provider(&state.db, &app_type)? { Some(id) => id, None => return Ok(()), }; let providers = state.db.get_all_providers(app_type.as_str())?; let Some(provider) = providers.get(¤t_id) else { return Ok(()); }; let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); // See the save path above: backup/placeholders are the ownership signal // here, not just proxy_config.enabled. if has_live_backup || live_taken_over { if matches!(app_type, AppType::ClaudeDesktop) { write_live_with_common_config_for_state(state, &app_type, provider)?; return Ok(()); } futures::executor::block_on( state .proxy_service .update_live_backup_from_provider(app_type.as_str(), provider), ) .map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?; return Ok(()); } sync_current_provider_for_app_to_live(state, &app_type) } pub fn migrate_legacy_common_config_usage( state: &AppState, app_type: AppType, legacy_snippet: &str, ) -> Result<(), AppError> { if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() { return Ok(()); } let providers = state.db.get_all_providers(app_type.as_str())?; for provider in providers.values() { if provider .meta .as_ref() .and_then(|meta| meta.common_config_enabled) .is_some() { continue; } if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) { continue; } let mut updated_provider = provider.clone(); updated_provider .meta .get_or_insert_with(Default::default) .common_config_enabled = Some(true); match live::remove_common_config_from_settings( &app_type, &updated_provider.settings_config, legacy_snippet, ) { Ok(settings) => updated_provider.settings_config = settings, Err(err) => { log::warn!( "Failed to normalize legacy common config for {} provider '{}': {err}", app_type.as_str(), updated_provider.id ); } } state .db .save_provider(app_type.as_str(), &updated_provider)?; } Ok(()) } pub fn migrate_legacy_common_config_usage_if_needed( state: &AppState, app_type: AppType, ) -> Result<(), AppError> { if app_type.is_additive_mode() { return Ok(()); } let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else { return Ok(()); }; if snippet.trim().is_empty() { return Ok(()); } Self::migrate_legacy_common_config_usage(state, app_type, &snippet) } /// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到 /// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。 /// /// 采用"整体重提取 + 替换"而非"只合并新增",是为了同时覆盖三种情况: /// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享 /// 偏好,被捕获进通用配置,切到别的供应商也带得过去; /// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被 /// 重新注入——否则会出现"插件怎么删也删不掉"的反直觉 bug; /// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。 /// /// 之所以"整体替换"是安全的:每次写 live 都会把当前片段合并进去,所以切走时 /// 读到的 live 一定是"片段 + 本地改动"的超集,重提取只会丢掉用户真正删掉的键, /// 不会误删其它供应商共享的内容。 /// /// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`) /// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` / /// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、 /// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token` /// fallback、`model_catalog_json`、`web_search = "disabled"` 哨兵——密钥与 /// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。 /// /// 仅对**显式勾选"写入通用配置"**(`meta.common_config_enabled == Some(true)`)的 /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的 /// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。 fn sync_common_config_snippet_from_live( state: &AppState, app_type: &AppType, provider: &Provider, live_config: &Value, result: &mut SwitchResult, ) { // 作用域限定 Claude + Codex(见函数文档)。 if !matches!(app_type, AppType::Claude | AppType::Codex) { return; } let opted_in = provider .meta .as_ref() .and_then(|meta| meta.common_config_enabled) == Some(true); if !opted_in { return; } match state.db.is_config_snippet_cleared(app_type.as_str()) { Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回 Ok(false) => {} Err(err) => { log::warn!( "Failed to read common config cleared flag for {}: {err}", app_type.as_str() ); return; } } let new_snippet = match Self::extract_common_config_snippet_from_settings( app_type.clone(), live_config, ) { Ok(snippet) => snippet, Err(err) => { log::warn!( "Failed to extract common config from live for {} provider '{}': {err}", app_type.as_str(), provider.id ); return; } }; // 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。 let current = state .db .get_config_snippet(app_type.as_str()) .ok() .flatten(); if current.as_deref() == Some(new_snippet.as_str()) { return; } if let Err(err) = state .db .set_config_snippet(app_type.as_str(), Some(new_snippet)) { log::warn!( "Failed to persist synced common config for {} provider '{}': {err}", app_type.as_str(), provider.id ); result .warnings .push(format!("common_config_sync_failed:{}", provider.id)); } } /// Extract common config snippet from current provider /// /// Extracts the current provider's configuration and removes provider-specific fields /// (API keys, model settings, endpoints) to create a reusable common config snippet. pub fn extract_common_config_snippet( state: &AppState, app_type: AppType, ) -> Result { // Get current provider let current_id = Self::current(state, app_type.clone())?; if current_id.is_empty() { return Err(AppError::Message("No current provider".to_string())); } let providers = state.db.get_all_providers(app_type.as_str())?; let provider = providers .get(¤t_id) .ok_or_else(|| AppError::Message(format!("Provider {current_id} not found")))?; match app_type { AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), AppType::ClaudeDesktop => Ok(String::new()), AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), AppType::GrokBuild => Ok(String::new()), AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } /// Extract common config snippet from a config value (e.g. editor content). pub fn extract_common_config_snippet_from_settings( app_type: AppType, settings_config: &Value, ) -> Result { match app_type { AppType::Claude => Self::extract_claude_common_config(settings_config), AppType::ClaudeDesktop => Ok(String::new()), AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::GrokBuild => Ok(String::new()), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets } } /// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的 /// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小 /// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用 /// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。 /// /// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY` /// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持 /// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` / /// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password / /// 私钥命名。 fn is_sensitive_config_key(name: &str) -> bool { let upper = name.to_ascii_uppercase(); // 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS` // (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。 const SENSITIVE_SUFFIXES: &[&str] = &[ "_API_KEY", "_APIKEY", "_AUTH_TOKEN", "_TOKEN", "_ACCESS_KEY", "_ACCESS_KEY_ID", "_KEY_ID", "_PRIVATE_KEY", ]; const SENSITIVE_EXACT: &[&str] = &[ "APIKEY", "API_KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIALS", ]; // contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET / // GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。 const SENSITIVE_CONTAINS: &[&str] = &[ "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "PRIVATE_KEY", "BEARER_TOKEN", ]; SENSITIVE_EXACT.contains(&upper.as_str()) || SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s)) || SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c)) } /// Extract common config for Claude (JSON format) fn extract_claude_common_config(settings: &Value) -> Result { let mut config = settings.clone(); // 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举, // 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY` // 等无需再手工补名单即可被覆盖。 const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[ "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", // Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属, // 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。 "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "CLAUDE_CODE_SUBAGENT_MODEL", // Context limits follow the actual upstream model. Sharing these // across providers can cap GPT/Kimi to the wrong window and make // Claude Code compact too early or miss the upstream limit. "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "ANTHROPIC_BASE_URL", ]; const TOP_LEVEL_EXCLUDES: &[&str] = &[ "apiBaseUrl", // Legacy model fields "primaryModel", "smallFastModel", ]; // Remove env fields: provider-specific (models/endpoint) + 任何凭据键。 if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { let sensitive: Vec = env .keys() .filter(|k| Self::is_sensitive_config_key(k)) .cloned() .collect(); for key in ENV_PROVIDER_SPECIFIC_EXCLUDES { env.remove(*key); } for key in &sensitive { env.remove(key); } // If env is empty after removal, remove the env object itself if env.is_empty() { config.as_object_mut().map(|obj| obj.remove("env")); } } // Remove top-level fields: legacy model fields + 任何凭据键 // (例如非标准的顶层 apiKey / api_key / *_TOKEN)。 if let Some(obj) = config.as_object_mut() { let sensitive: Vec = obj .keys() .filter(|k| Self::is_sensitive_config_key(k)) .cloned() .collect(); for key in TOP_LEVEL_EXCLUDES { obj.remove(*key); } for key in &sensitive { obj.remove(key); } } // Check if result is empty if config.as_object().is_none_or(|obj| obj.is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Extract common config for Codex (TOML format) fn extract_codex_common_config(settings: &Value) -> Result { // Codex config is stored as { "auth": {...}, "config": "toml string" } let config_toml = settings .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); if config_toml.is_empty() { return Ok(String::new()); } let mut doc = config_toml .parse::() .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?; // Remove provider-specific fields. let root = doc.as_table_mut(); root.remove("model"); root.remove("model_provider"); // Legacy/alt formats might use a top-level base_url. root.remove("base_url"); // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时 // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层, // 进了片段会改写其它供应商的协议选择(chat vs responses)。 root.remove("wire_api"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的 // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里 // 显示为一份"重复"的 MCP 配置。 root.remove("mcp_servers"); // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了 // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。 if let Some(mcp_tbl) = root .get_mut("mcp") .and_then(|item| item.as_table_like_mut()) { mcp_tbl.remove("servers"); if mcp_tbl.is_empty() { root.remove("mcp"); } } // cc-switch 写 live 时注入的产物一律不进共享片段: // - experimental_bearer_token 正常写在 [model_providers.] 内(上面 // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback // 会落在顶层——不剥等于把 API 密钥写进共享片段。 root.remove("experimental_bearer_token"); // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。 root.remove("model_catalog_json"); // - web_search 只剥 cc-switch 注入的 "disabled" 哨兵;用户手设的其它值 // 属于可共享偏好,保留。 if root .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD) .and_then(|item| item.as_str()) == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED) { root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD); } // Clean up multiple empty lines (keep at most one blank line). let mut cleaned = String::new(); let mut blank_run = 0usize; for line in doc.to_string().lines() { if line.trim().is_empty() { blank_run += 1; if blank_run <= 1 { cleaned.push('\n'); } continue; } blank_run = 0; cleaned.push_str(line); cleaned.push('\n'); } Ok(cleaned.trim().to_string()) } /// Extract common config for Gemini (JSON format) /// /// Extracts `.env` values while excluding provider-specific credentials: /// - GOOGLE_GEMINI_BASE_URL /// - GEMINI_API_KEY fn extract_gemini_common_config(settings: &Value) -> Result { let env = settings.get("env").and_then(|v| v.as_object()); let mut snippet = serde_json::Map::new(); if let Some(env) = env { for (key, value) in env { if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" { continue; } let Value::String(v) = value else { continue; }; let trimmed = v.trim(); if !trimmed.is_empty() { snippet.insert(key.to_string(), Value::String(trimmed.to_string())); } } } if snippet.is_empty() { return Ok("{}".to_string()); } serde_json::to_string_pretty(&Value::Object(snippet)) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Extract common config for OpenCode (JSON format) fn extract_opencode_common_config(settings: &Value) -> Result { // OpenCode uses a different config structure with npm, options, models // For common config, we exclude provider-specific fields like apiKey let mut config = settings.clone(); // Remove provider-specific fields if let Some(obj) = config.as_object_mut() { if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) { options.remove("apiKey"); options.remove("baseURL"); } // Keep npm and models as they might be common } if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Extract common config for OpenClaw (JSON format) fn extract_openclaw_common_config(settings: &Value) -> Result { // OpenClaw uses a different config structure with baseUrl, apiKey, api, models // For common config, we exclude provider-specific fields like apiKey let mut config = settings.clone(); // Remove provider-specific fields if let Some(obj) = config.as_object_mut() { obj.remove("apiKey"); obj.remove("baseUrl"); // Keep api and models as they might be common } if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) { return Ok("{}".to_string()); } serde_json::to_string_pretty(&config) .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) } /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { import_default_config(state, app_type) } pub fn should_import_default_config_on_startup( state: &AppState, app_type: &AppType, ) -> Result { should_import_default_config_on_startup(state, app_type) } /// Read current live settings (re-export) pub fn read_live_settings(app_type: AppType) -> Result { read_live_settings(app_type) } /// Get custom endpoints list (re-export) pub fn get_custom_endpoints( state: &AppState, app_type: AppType, provider_id: &str, ) -> Result, AppError> { endpoints::get_custom_endpoints(state, app_type, provider_id) } /// Add custom endpoint (re-export) pub fn add_custom_endpoint( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { endpoints::add_custom_endpoint(state, app_type, provider_id, url) } /// Remove custom endpoint (re-export) pub fn remove_custom_endpoint( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { endpoints::remove_custom_endpoint(state, app_type, provider_id, url) } /// Update endpoint last used timestamp (re-export) pub fn update_endpoint_last_used( state: &AppState, app_type: AppType, provider_id: &str, url: String, ) -> Result<(), AppError> { endpoints::update_endpoint_last_used(state, app_type, provider_id, url) } /// Update provider sort order pub fn update_sort_order( state: &AppState, app_type: AppType, updates: Vec, ) -> Result { let mut providers = state.db.get_all_providers(app_type.as_str())?; for update in updates { if let Some(provider) = providers.get_mut(&update.id) { provider.sort_index = Some(update.sort_index); state.db.save_provider(app_type.as_str(), provider)?; } } Ok(true) } /// Query provider usage (re-export) pub async fn query_usage( state: &AppState, app_type: AppType, provider_id: &str, ) -> Result { usage::query_usage(state, app_type, provider_id).await } /// Test usage script (re-export) #[allow(clippy::too_many_arguments)] pub async fn test_usage_script( state: &AppState, app_type: AppType, provider_id: &str, script_code: &str, timeout: u64, api_key: Option<&str>, base_url: Option<&str>, access_token: Option<&str>, user_id: Option<&str>, template_type: Option<&str>, ) -> Result { usage::test_usage_script( state, app_type, provider_id, script_code, timeout, api_key, base_url, access_token, user_id, template_type, ) .await } pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> { write_gemini_live(provider) } fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> { match app_type { AppType::Claude => { if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.claude.settings.not_object", "Claude 配置必须是 JSON 对象", "Claude configuration must be a JSON object", )); } } AppType::ClaudeDesktop => { crate::claude_desktop_config::validate_provider(provider)?; } AppType::Codex => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( "provider.codex.settings.not_object", "Codex 配置必须是 JSON 对象", "Codex configuration must be a JSON object", ) })?; let auth = settings.get("auth").ok_or_else(|| { AppError::localized( "provider.codex.auth.missing", format!("供应商 {} 缺少 auth 配置", provider.id), format!("Provider {} is missing auth configuration", provider.id), ) })?; if !auth.is_object() { return Err(AppError::localized( "provider.codex.auth.not_object", format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id), format!( "Provider {} auth configuration must be a JSON object", provider.id ), )); } if let Some(config_value) = settings.get("config") { if !(config_value.is_string() || config_value.is_null()) { return Err(AppError::localized( "provider.codex.config.invalid_type", "Codex config 字段必须是字符串", "Codex config field must be a string", )); } if let Some(cfg_text) = config_value.as_str() { crate::codex_config::validate_config_toml(cfg_text)?; } } } AppType::Gemini => { use crate::gemini_config::validate_gemini_settings; validate_gemini_settings(&provider.settings_config)? } AppType::GrokBuild => { let settings = provider.settings_config.as_object().ok_or_else(|| { AppError::localized( "provider.grokbuild.settings.not_object", "Grok Build 配置必须是 JSON 对象", "Grok Build configuration must be a JSON object", ) })?; let config = settings .get("config") .and_then(Value::as_str) .ok_or_else(|| { AppError::localized( "provider.grokbuild.config.missing", "Grok Build 配置缺少 config 字段", "Grok Build configuration is missing the config field", ) })?; crate::grok_config::validate_config_toml(config)?; } AppType::OpenCode => { // OpenCode uses a different config structure: { npm, options, models } // Basic validation - must be an object if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.opencode.settings.not_object", "OpenCode 配置必须是 JSON 对象", "OpenCode configuration must be a JSON object", )); } } AppType::OpenClaw => { // OpenClaw uses config structure: { baseUrl, apiKey, api, models } // Basic validation - must be an object if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.openclaw.settings.not_object", "OpenClaw 配置必须是 JSON 对象", "OpenClaw configuration must be a JSON object", )); } } AppType::Hermes => { // Hermes: accept any JSON object for now if !provider.settings_config.is_object() { return Err(AppError::localized( "provider.hermes.settings.not_object", "Hermes 配置必须是 JSON 对象", "Hermes configuration must be a JSON object", )); } } } // Validate and clean UsageScript configuration (common for all app types) if let Some(meta) = &provider.meta { if let Some(multiplier) = meta.cost_multiplier.as_deref() { validate_cost_multiplier(multiplier)?; } if let Some(source) = meta.pricing_model_source.as_deref() { validate_pricing_source(source)?; } if let Some(usage_script) = &meta.usage_script { validate_usage_script(usage_script)?; } } Ok(()) } #[allow(dead_code)] fn extract_credentials( provider: &Provider, app_type: &AppType, ) -> Result<(String, String), AppError> { match app_type { AppType::Claude => { let env = provider .settings_config .get("env") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.claude.env.missing", "配置格式错误: 缺少 env", "Invalid configuration: missing env section", ) })?; let api_key = env .get("ANTHROPIC_AUTH_TOKEN") .or_else(|| env.get("ANTHROPIC_API_KEY")) .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.claude.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = env .get("ANTHROPIC_BASE_URL") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.claude.base_url.missing", "缺少 ANTHROPIC_BASE_URL 配置", "Missing ANTHROPIC_BASE_URL configuration", ) })? .to_string(); Ok((api_key, base_url)) } AppType::GrokBuild => { let config_toml = provider .settings_config .get("config") .and_then(Value::as_str) .ok_or_else(|| { AppError::localized( "provider.grokbuild.config.missing", "Grok Build 配置缺少 config 字段", "Grok Build configuration is missing the config field", ) })?; let (base_url, api_key) = crate::grok_config::extract_credentials(config_toml) .ok_or_else(|| { AppError::localized( "provider.grokbuild.credentials.missing", "Grok Build 配置缺少 Base URL 或 API Key", "Grok Build configuration is missing the base URL or API key", ) })?; Ok((api_key, base_url)) } AppType::ClaudeDesktop => { let credentials = crate::claude_desktop_config::direct_gateway_credentials(provider)?; Ok((credentials.api_key, credentials.base_url)) } AppType::Codex => { let _auth = provider .settings_config .get("auth") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.codex.auth.missing", "配置格式错误: 缺少 auth", "Invalid configuration: missing auth section", ) })?; let config_toml = provider .settings_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); let api_key = crate::codex_config::extract_codex_api_key( provider.settings_config.get("auth"), Some(config_toml), ) .ok_or_else(|| { AppError::localized( "provider.codex.api_key.missing", "缺少 API Key", "API key is missing", ) })?; let base_url = if config_toml.contains("base_url") { let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { AppError::localized( "provider.regex_init_failed", format!("正则初始化失败: {e}"), format!("Failed to initialize regex: {e}"), ) })?; re.captures(config_toml) .and_then(|caps| caps.get(1)) .map(|m| m.as_str().to_string()) .ok_or_else(|| { AppError::localized( "provider.codex.base_url.invalid", "config.toml 中 base_url 格式错误", "base_url in config.toml has invalid format", ) })? } else { return Err(AppError::localized( "provider.codex.base_url.missing", "config.toml 中缺少 base_url 配置", "base_url is missing from config.toml", )); }; Ok((api_key, base_url)) } AppType::Gemini => { use crate::gemini_config::json_to_env; let env_map = json_to_env(&provider.settings_config)?; let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| { AppError::localized( "gemini.missing_api_key", "缺少 GEMINI_API_KEY", "Missing GEMINI_API_KEY", ) })?; let base_url = env_map .get("GOOGLE_GEMINI_BASE_URL") .cloned() .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()); Ok((api_key, base_url)) } AppType::OpenCode => { // OpenCode uses options.apiKey and options.baseURL let options = provider .settings_config .get("options") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::localized( "provider.opencode.options.missing", "配置格式错误: 缺少 options", "Invalid configuration: missing options section", ) })?; let api_key = options .get("apiKey") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.opencode.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = options .get("baseURL") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok((api_key, base_url)) } AppType::OpenClaw | AppType::Hermes => { // OpenClaw/Hermes use apiKey and baseUrl directly on the object let api_key = provider .settings_config .get("apiKey") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.openclaw.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let base_url = provider .settings_config .get("baseUrl") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok((api_key, base_url)) } } } } /// Normalize Claude model keys in a JSON value /// /// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key. pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool { let mut changed = false; let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) { Some(obj) => obj, None => return changed, }; let model = env .get("ANTHROPIC_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let small_fast = env .get("ANTHROPIC_SMALL_FAST_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_haiku = env .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_sonnet = env .get("ANTHROPIC_DEFAULT_SONNET_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let current_opus = env .get("ANTHROPIC_DEFAULT_OPUS_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let target_haiku = current_haiku .or_else(|| small_fast.clone()) .or_else(|| model.clone()); let target_sonnet = current_sonnet .or_else(|| model.clone()) .or_else(|| small_fast.clone()); let target_opus = current_opus .or_else(|| model.clone()) .or_else(|| small_fast.clone()); if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() { if let Some(v) = target_haiku { env.insert( "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), Value::String(v), ); changed = true; } } if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() { if let Some(v) = target_sonnet { env.insert( "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), Value::String(v), ); changed = true; } } if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() { if let Some(v) = target_opus { env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v)); changed = true; } } if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() { changed = true; } changed } #[derive(Debug, Clone, Deserialize)] pub struct ProviderSortUpdate { pub id: String, #[serde(rename = "sortIndex")] pub sort_index: usize, } // ============================================================================ // 统一供应商(Universal Provider)服务方法 // ============================================================================ use crate::provider::UniversalProvider; use std::collections::HashMap; impl ProviderService { /// 获取所有统一供应商 pub fn list_universal( state: &AppState, ) -> Result, AppError> { state.db.get_all_universal_providers() } /// 获取单个统一供应商 pub fn get_universal( state: &AppState, id: &str, ) -> Result, AppError> { state.db.get_universal_provider(id) } /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps) pub fn upsert_universal( state: &AppState, provider: UniversalProvider, ) -> Result { // 保存统一供应商 state.db.save_universal_provider(&provider)?; Ok(true) } /// 删除统一供应商 pub fn delete_universal(state: &AppState, id: &str) -> Result { // 获取统一供应商(用于删除生成的子供应商) let provider = state.db.get_universal_provider(id)?; // 删除统一供应商 state.db.delete_universal_provider(id)?; // 删除生成的子供应商 if let Some(p) = provider { if p.apps.claude { let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } if p.apps.codex { let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } if p.apps.gemini { let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } } Ok(true) } /// 同步统一供应商到各应用 pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result { let provider = state .db .get_universal_provider(id)? .ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?; // 同步到 Claude if let Some(mut claude_provider) = provider.to_claude_provider() { // 合并已有配置 if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? { let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &claude_provider.settings_config); claude_provider.settings_config = merged; } state.db.save_provider("claude", &claude_provider)?; } else { // 如果禁用了 Claude,删除对应的子供应商 let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } // 同步到 Codex if let Some(mut codex_provider) = provider.to_codex_provider() { // 合并已有配置 if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &codex_provider.settings_config); codex_provider.settings_config = merged; } state.db.save_provider("codex", &codex_provider)?; } else { let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } // 同步到 Gemini if let Some(mut gemini_provider) = provider.to_gemini_provider() { // 合并已有配置 if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? { let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &gemini_provider.settings_config); gemini_provider.settings_config = merged; } state.db.save_provider("gemini", &gemini_provider)?; } else { let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } Ok(true) } /// 递归合并 JSON:base 为底,patch 覆盖同名字段 fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) { use serde_json::Value; match (base, patch) { (Value::Object(base_map), Value::Object(patch_map)) => { for (k, v_patch) in patch_map { match base_map.get_mut(k) { Some(v_base) => Self::merge_json(v_base, v_patch), None => { base_map.insert(k.clone(), v_patch.clone()); } } } } // 其它类型:直接覆盖 (base_val, patch_val) => { *base_val = patch_val.clone(); } } } }