//! 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::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, sync_current_to_live, }; // Internal re-exports (pub(crate)) pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{ 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, }; // 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; /// 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::*; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; use crate::provider::ProviderMeta; 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, 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(); let original_userprofile = env::var("USERPROFILE").ok(); let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok(); env::set_var("HOME", dir.path()); env::set_var("USERPROFILE", dir.path()); env::set_var("CC_SWITCH_TEST_HOME", dir.path()); Self { dir, original_home, 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"), } 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"), } } } 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 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 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] 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_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_preserves_mcp_servers_base_url() { let config_toml = r#"model_provider = "azure" model = "gpt-4" disable_response_storage = true [model_providers.azure] name = "Azure OpenAI" base_url = "https://azure.example/v1" wire_api = "responses" [mcp_servers.my_server] base_url = "http://localhost:8080" "#; 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" ); assert!( extracted.contains("http://localhost:8080"), "should keep mcp_servers.* base_url" ); } #[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, ..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"); 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("http://127.0.0.1:15721"), "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" ); } #[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 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_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 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 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); } /// 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)?; 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(state.db.as_ref(), &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() { // No current provider, set as current and sync state .db .set_current_provider(app_type.as_str(), &provider.id)?; write_live_with_common_config(state.db.as_ref(), &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; 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)?; 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(state.db.as_ref(), &app_type, &provider)?; return Ok(true); } // Save to database state.db.save_provider(app_type.as_str(), &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()); 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 is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over); if should_sync_via_proxy { 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}")))?; 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 { write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; // Sync MCP McpService::sync_all_enabled(state)?; } } 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 only (no Live config write) /// 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); } // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function let is_app_taken_over = futures::executor::block_on(state.db.get_live_backup(app_type.as_str())) .ok() .flatten() .is_some(); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); let live_taken_over = state .proxy_service .detect_takeover_in_live_config_for_app(&app_type); // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; // 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") { 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 only, don't write Live config log::info!( "代理接管模式:热切换 {} 的目标供应商为 {}", app_type.as_str(), id ); futures::executor::block_on( state .proxy_service .hot_switch_provider(app_type.as_str(), id), ) .map_err(|e| AppError::Message(format!("热切换失败: {e}")))?; // Note: No Live config write, no MCP sync // The proxy server will route requests to the new provider via is_current 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)?; 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() { 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}")); } } } } } } // Additive mode apps skip setting is_current (no such concept) if !app_type.is_additive_mode() { // Update local settings (device-level, takes priority) crate::settings::set_current_provider(&app_type, Some(id))?; // Update database is_current (as default for new devices) state.db.set_current_provider(app_type.as_str(), id)?; } // Sync to live (write_gemini_live handles security flag internally for Gemini) write_live_with_common_config(state.db.as_ref(), &app_type, provider)?; // 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 ))); } } } } // Sync MCP McpService::sync_all_enabled(state)?; 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 takeover_enabled = futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str())) .map(|config| config.enabled) .unwrap_or(false); 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); if takeover_enabled && (has_live_backup || live_taken_over) { 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) } /// 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::Codex => Self::extract_codex_common_config(&provider.settings_config), AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), 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::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), 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 } } /// Extract common config for Claude (JSON format) fn extract_claude_common_config(settings: &Value) -> Result { let mut config = settings.clone(); // Fields to exclude from common config const ENV_EXCLUDES: &[&str] = &[ // Auth "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", // Models (4 fields + 1 legacy) "ANTHROPIC_MODEL", "ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留 "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", // Endpoint "ANTHROPIC_BASE_URL", ]; const TOP_LEVEL_EXCLUDES: &[&str] = &[ "apiBaseUrl", // Legacy model fields "primaryModel", "smallFastModel", ]; // Remove env fields if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { for key in ENV_EXCLUDES { 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 if let Some(obj) = config.as_object_mut() { for key in TOP_LEVEL_EXCLUDES { 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"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); // 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) } /// 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::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::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(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::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 api_key = auth .get("OPENAI_API_KEY") .and_then(|v| v.as_str()) .ok_or_else(|| { AppError::localized( "provider.codex.api_key.missing", "缺少 API Key", "API key is missing", ) })? .to_string(); let config_toml = provider .settings_config .get("config") .and_then(|v| v.as_str()) .unwrap_or(""); 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(); } } } }