diff --git a/src-tauri/src/commands/cc_switch_model_provider_import.rs b/src-tauri/src/commands/cc_switch_model_provider_import.rs new file mode 100644 index 000000000..64744e00e --- /dev/null +++ b/src-tauri/src/commands/cc_switch_model_provider_import.rs @@ -0,0 +1,525 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sea_orm::{ + ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, QueryResult, + Statement, +}; +use serde_json::{Map, Value}; + +use crate::app_error::AppCommandError; +use crate::db::entities::model_provider; +use crate::models::model_provider::{ + CcSwitchModelProviderPreviewItem, CcSwitchModelProviderSkipReason, +}; + +#[derive(Debug, Clone)] +struct CcSwitchProviderRow { + id: String, + app_type: String, + name: String, + settings_config: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedCcSwitchModelProvider { + pub source_id: String, + pub source_app_type: String, + pub target_agent_type: String, + pub name: String, + pub api_url: String, + pub api_key: String, + pub model: Option, +} + +#[derive(Debug, Clone)] +struct LoadedCcSwitchProvider { + preview: CcSwitchModelProviderPreviewItem, + resolved: Option, +} + +pub(crate) fn resolve_cc_switch_db_path() -> PathBuf { + dirs::home_dir() + .map(|home| home.join(".cc-switch").join("cc-switch.db")) + .unwrap_or_else(|| PathBuf::from(".cc-switch/cc-switch.db")) +} + +pub(crate) async fn load_cc_switch_preview_items( + db_path: &Path, + existing: &[model_provider::Model], +) -> Result, AppCommandError> { + Ok(load_cc_switch_sources(db_path, existing) + .await? + .into_iter() + .map(|item| item.preview) + .collect()) +} + +pub(crate) async fn load_cc_switch_import_candidates( + db_path: &Path, + existing: &[model_provider::Model], +) -> Result, AppCommandError> { + Ok(load_cc_switch_sources(db_path, existing) + .await? + .into_iter() + .filter_map(|item| item.resolved) + .collect()) +} + +async fn load_cc_switch_sources( + db_path: &Path, + existing: &[model_provider::Model], +) -> Result, AppCommandError> { + let conn = open_cc_switch_sqlite_connection(db_path).await?; + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT id, app_type, name, settings_config FROM providers ORDER BY app_type ASC, name ASC, id ASC" + .to_owned(), + )) + .await + .map_err(|err| { + AppCommandError::database_error("Failed to query cc-switch providers") + .with_detail(err.to_string()) + })?; + + Ok(rows + .iter() + .map(parse_cc_switch_provider_row) + .map(|row| normalize_cc_switch_provider_row(&row, existing)) + .collect()) +} + +async fn open_cc_switch_sqlite_connection( + db_path: &Path, +) -> Result { + let db_url = format!( + "sqlite:{}?mode=ro", + urlencoding::encode(&db_path.to_string_lossy()) + ); + let mut opts = ConnectOptions::new(db_url); + opts.max_connections(1) + .min_connections(1) + .connect_timeout(Duration::from_secs(5)) + .idle_timeout(Duration::from_secs(30)) + .sqlx_logging(false); + + let conn = Database::connect(opts).await.map_err(|err| { + AppCommandError::database_error("Failed to open cc-switch database") + .with_detail(err.to_string()) + })?; + conn.execute(Statement::from_string( + DbBackend::Sqlite, + "PRAGMA busy_timeout=3000;".to_owned(), + )) + .await + .map_err(|err| { + AppCommandError::database_error("Failed to configure cc-switch database") + .with_detail(err.to_string()) + })?; + Ok(conn) +} + +fn parse_cc_switch_provider_row(row: &QueryResult) -> CcSwitchProviderRow { + let id: String = row.try_get("", "id").unwrap_or_default(); + let app_type: String = row.try_get("", "app_type").unwrap_or_default(); + let name: String = row.try_get("", "name").unwrap_or_default(); + let raw_settings: Option = row.try_get("", "settings_config").ok(); + let settings_config = raw_settings + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + + CcSwitchProviderRow { + id, + app_type, + name, + settings_config, + } +} + +fn normalize_cc_switch_provider_row( + row: &CcSwitchProviderRow, + existing: &[model_provider::Model], +) -> LoadedCcSwitchProvider { + let source_id = format!("{}:{}", row.app_type, row.id); + let name = row.name.trim().to_string(); + let skip = |reason: CcSwitchModelProviderSkipReason, + api_url: Option, + model: Option, + target_agent_type: &str| LoadedCcSwitchProvider { + preview: CcSwitchModelProviderPreviewItem { + source_id: source_id.clone(), + source_app_type: row.app_type.clone(), + target_agent_type: target_agent_type.to_string(), + name: name.clone(), + api_url, + model, + importable: false, + skip_reason: Some(reason), + }, + resolved: None, + }; + + if name.is_empty() { + return skip(CcSwitchModelProviderSkipReason::MissingName, None, None, ""); + } + + let Some(settings) = row.settings_config.as_ref() else { + return skip( + CcSwitchModelProviderSkipReason::MalformedSource, + None, + None, + target_agent_type_for(row.app_type.as_str()), + ); + }; + + let target_agent_type = target_agent_type_for(row.app_type.as_str()); + let Some((api_url, api_key, model)) = extract_fields(row.app_type.as_str(), settings) else { + let reason = if !matches!( + row.app_type.as_str(), + "claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes" + ) { + CcSwitchModelProviderSkipReason::UnsupportedAppType + } else { + CcSwitchModelProviderSkipReason::MalformedSource + }; + return skip(reason, None, None, target_agent_type); + }; + + let preview_api_url = (!api_url.trim().is_empty()).then_some(api_url.clone()); + let preview_model = model.clone(); + + if api_url.trim().is_empty() { + return skip( + CcSwitchModelProviderSkipReason::MissingApiUrl, + preview_api_url, + preview_model, + target_agent_type, + ); + } + if api_key.trim().is_empty() { + return skip( + CcSwitchModelProviderSkipReason::MissingApiKey, + preview_api_url, + preview_model, + target_agent_type, + ); + } + if crate::commands::model_provider::validate_fields(Some(&name), Some(&api_url), Some(&api_key)) + .is_err() + { + return skip( + CcSwitchModelProviderSkipReason::MalformedSource, + preview_api_url, + preview_model, + target_agent_type, + ); + } + if crate::commands::model_provider::validate_model(target_agent_type, model.as_deref()).is_err() + { + return skip( + CcSwitchModelProviderSkipReason::InvalidModel, + preview_api_url, + preview_model, + target_agent_type, + ); + } + + let resolved = ResolvedCcSwitchModelProvider { + source_id: source_id.clone(), + source_app_type: row.app_type.clone(), + target_agent_type: target_agent_type.to_string(), + name: name.clone(), + api_url, + api_key, + model, + }; + + if let Some(reason) = duplicate_reason_for_candidate(&resolved, existing) { + let keep_resolved = matches!(reason, CcSwitchModelProviderSkipReason::DuplicateName); + return LoadedCcSwitchProvider { + preview: CcSwitchModelProviderPreviewItem { + source_id, + source_app_type: row.app_type.clone(), + target_agent_type: resolved.target_agent_type.clone(), + name, + api_url: Some(resolved.api_url.clone()), + model: resolved.model.clone(), + importable: false, + skip_reason: Some(reason), + }, + resolved: keep_resolved.then_some(resolved), + }; + } + + LoadedCcSwitchProvider { + preview: CcSwitchModelProviderPreviewItem { + source_id, + source_app_type: row.app_type.clone(), + target_agent_type: resolved.target_agent_type.clone(), + name, + api_url: Some(resolved.api_url.clone()), + model: resolved.model.clone(), + importable: true, + skip_reason: None, + }, + resolved: Some(resolved), + } +} + +pub(crate) fn duplicate_reason_for_candidate( + candidate: &ResolvedCcSwitchModelProvider, + existing: &[model_provider::Model], +) -> Option { + if existing + .iter() + .any(|provider| provider.name == candidate.name) + { + return Some(CcSwitchModelProviderSkipReason::DuplicateName); + } + + existing + .iter() + .any(|provider| { + provider.agent_type == candidate.target_agent_type + && provider.api_url == candidate.api_url + && provider.api_key == candidate.api_key + && provider.model.as_deref() == candidate.model.as_deref() + }) + .then_some(CcSwitchModelProviderSkipReason::DuplicateConfig) +} + +fn target_agent_type_for(app_type: &str) -> &str { + match app_type { + "claude" => "claude_code", + "codex" => "codex", + "gemini" => "gemini", + "opencode" => "open_code", + "openclaw" => "open_claw", + "hermes" => "hermes", + _ => "", + } +} + +fn extract_fields(app_type: &str, settings: &Value) -> Option<(String, String, Option)> { + match app_type { + "claude" => extract_claude(settings), + "codex" => extract_codex(settings), + "gemini" => extract_gemini(settings), + "opencode" | "openclaw" => extract_openai_compatible(settings), + "hermes" => extract_hermes(settings), + _ => None, + } +} + +fn extract_claude(settings: &Value) -> Option<(String, String, Option)> { + let env = settings.get("env")?.as_object()?; + let api_url = get_object_string(env, "ANTHROPIC_BASE_URL")?; + let api_key = get_object_string(env, "ANTHROPIC_AUTH_TOKEN") + .or_else(|| get_object_string(env, "ANTHROPIC_API_KEY"))?; + + let mut model = Map::new(); + for (field, env_key) in [ + ("main", "ANTHROPIC_MODEL"), + ("reasoning", "ANTHROPIC_REASONING_MODEL"), + ("haiku", "ANTHROPIC_DEFAULT_HAIKU_MODEL"), + ("sonnet", "ANTHROPIC_DEFAULT_SONNET_MODEL"), + ("opus", "ANTHROPIC_DEFAULT_OPUS_MODEL"), + ("customOption", "ANTHROPIC_CUSTOM_MODEL_OPTION"), + ("customOptionName", "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"), + ( + "customOptionDescription", + "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", + ), + ] { + if let Some(value) = get_object_string(env, env_key) { + model.insert(field.to_string(), Value::String(value)); + } + } + let serialized_model = if model.is_empty() { + None + } else { + serde_json::to_string(&Value::Object(model)).ok() + }; + + Some((api_url, api_key, serialized_model)) +} + +fn extract_codex(settings: &Value) -> Option<(String, String, Option)> { + let config_toml = settings.get("config")?.as_str()?; + let config: toml::Value = toml::from_str(config_toml).ok()?; + let api_url = config.get("base_url")?.as_str()?.trim().to_string(); + let model = config + .get("model") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let api_key = settings + .get("auth") + .and_then(|value| value.get("OPENAI_API_KEY")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + settings + .get("env") + .and_then(|value| value.as_object()) + .and_then(|env| get_object_string(env, "CODEX_API_KEY")) + })?; + + Some((api_url, api_key, model)) +} + +fn extract_gemini(settings: &Value) -> Option<(String, String, Option)> { + let env = settings.get("env")?.as_object()?; + Some(( + get_object_string(env, "GOOGLE_GEMINI_BASE_URL")?, + get_object_string(env, "GEMINI_API_KEY")?, + get_object_string(env, "GEMINI_MODEL"), + )) +} + +fn extract_openai_compatible(settings: &Value) -> Option<(String, String, Option)> { + Some(( + get_value_string(settings, &["baseUrl", "base_url"])?, + get_value_string(settings, &["apiKey", "api_key"])?, + Some(get_value_string(settings, &["model"])?), + )) +} + +fn extract_hermes(settings: &Value) -> Option<(String, String, Option)> { + let api_url = get_value_string(settings, &["base_url"])?; + let api_key = get_value_string(settings, &["api_key"])?; + let model = settings + .get("models") + .and_then(|value| value.as_array()) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned)?; + Some((api_url, api_key, Some(model))) +} + +fn get_object_string(map: &Map, key: &str) -> Option { + map.get(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn get_value_string(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key)) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_gemini_row_into_importable_preview() { + let row = CcSwitchProviderRow { + id: "gem-1".to_string(), + app_type: "gemini".to_string(), + name: "Gemini Demo".to_string(), + settings_config: Some(serde_json::json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://api.example.com", + "GEMINI_API_KEY": "gm-key", + "GEMINI_MODEL": "gemini-3.5-flash" + } + })), + }; + + let item = normalize_cc_switch_provider_row(&row, &[]); + assert!(item.preview.importable); + assert_eq!(item.preview.target_agent_type, "gemini"); + assert_eq!( + item.preview.api_url.as_deref(), + Some("https://api.example.com") + ); + assert_eq!(item.preview.model.as_deref(), Some("gemini-3.5-flash")); + assert!(item.resolved.is_some()); + } + + #[test] + fn skips_duplicate_name_even_when_config_differs() { + let row = CcSwitchProviderRow { + id: "codex-1".to_string(), + app_type: "codex".to_string(), + name: "Shared Name".to_string(), + settings_config: Some(serde_json::json!({ + "auth": { "OPENAI_API_KEY": "sk-demo" }, + "config": "model = \"gpt-5\"\nbase_url = \"https://api.example.com/v1\"\n" + })), + }; + let existing = vec![model_provider::Model { + id: 1, + name: "Shared Name".to_string(), + api_url: "https://other.example.com/v1".to_string(), + api_key: "sk-other".to_string(), + agent_types_json: "[\"codex\"]".to_string(), + agent_type: "codex".to_string(), + model: Some("gpt-5".to_string()), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }]; + + let item = normalize_cc_switch_provider_row(&row, &existing); + assert!(!item.preview.importable); + assert_eq!( + item.preview.skip_reason, + Some(CcSwitchModelProviderSkipReason::DuplicateName) + ); + } + + #[test] + fn skips_openai_compatible_row_without_model() { + let row = CcSwitchProviderRow { + id: "opencode-1".to_string(), + app_type: "opencode".to_string(), + name: "OpenCode Missing Model".to_string(), + settings_config: Some(serde_json::json!({ + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-demo" + })), + }; + + let item = normalize_cc_switch_provider_row(&row, &[]); + assert!(!item.preview.importable); + assert_eq!( + item.preview.skip_reason, + Some(CcSwitchModelProviderSkipReason::MalformedSource) + ); + } + + #[test] + fn skips_hermes_row_without_model() { + let row = CcSwitchProviderRow { + id: "hermes-1".to_string(), + app_type: "hermes".to_string(), + name: "Hermes Missing Model".to_string(), + settings_config: Some(serde_json::json!({ + "base_url": "https://api.example.com/v1", + "api_key": "sk-demo", + "models": [] + })), + }; + + let item = normalize_cc_switch_provider_row(&row, &[]); + assert!(!item.preview.importable); + assert_eq!( + item.preview.skip_reason, + Some(CcSwitchModelProviderSkipReason::MalformedSource) + ); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 63c11c5b6..a35352b9b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod acp; pub mod app_update; pub mod automation; pub mod backup; +pub mod cc_switch_model_provider_import; pub mod chat_channel; pub mod conversations; pub mod delegation; diff --git a/src-tauri/src/commands/model_provider.rs b/src-tauri/src/commands/model_provider.rs index 1db8b93c0..10b42e8d0 100644 --- a/src-tauri/src/commands/model_provider.rs +++ b/src-tauri/src/commands/model_provider.rs @@ -3,11 +3,15 @@ use std::path::Path; use crate::acp::manager::ConnectionManager; use crate::acp::types::ConfigStaleKind; use crate::app_error::AppCommandError; -use crate::commands::acp; +use crate::commands::{acp, cc_switch_model_provider_import}; use crate::db::service::{agent_setting_service, model_provider_service}; use crate::db::AppDatabase; use crate::models::agent::AgentType; -use crate::models::model_provider::ModelProviderInfo; +use crate::models::model_provider::{ + CcSwitchModelProviderPreviewItem, ImportCcSwitchModelProvidersRequest, + ImportCcSwitchModelProvidersResult, ListImportableCcSwitchModelProvidersResult, + ModelProviderInfo, +}; use crate::web::event_bridge::EventEmitter; // --------------------------------------------------------------------------- @@ -23,7 +27,7 @@ fn validate_agent_type(agent_type: &str) -> Result<(), AppCommandError> { Ok(()) } -fn validate_fields( +pub(crate) fn validate_fields( name: Option<&str>, api_url: Option<&str>, api_key: Option<&str>, @@ -57,7 +61,7 @@ fn validate_fields( Ok(()) } -fn validate_model(agent_type: &str, model: Option<&str>) -> Result<(), AppCommandError> { +pub(crate) fn validate_model(agent_type: &str, model: Option<&str>) -> Result<(), AppCommandError> { let Some(raw) = model.map(str::trim).filter(|s| !s.is_empty()) else { return Ok(()); }; @@ -108,6 +112,150 @@ pub async fn create_model_provider_core( Ok(ModelProviderInfo::from(model_row)) } +async fn list_importable_cc_switch_model_providers_from_path_core( + db: &AppDatabase, + db_path: &Path, +) -> Result { + if !db_path.is_file() { + return Ok(ListImportableCcSwitchModelProvidersResult { + available: false, + source_path: db_path.display().to_string(), + items: Vec::new(), + }); + } + + let existing = model_provider_service::list_all(&db.conn) + .await + .map_err(AppCommandError::from)?; + let items = + cc_switch_model_provider_import::load_cc_switch_preview_items(db_path, &existing).await?; + + Ok(ListImportableCcSwitchModelProvidersResult { + available: true, + source_path: db_path.display().to_string(), + items, + }) +} + +pub async fn list_importable_cc_switch_model_providers_core( + db: &AppDatabase, +) -> Result { + let db_path = cc_switch_model_provider_import::resolve_cc_switch_db_path(); + list_importable_cc_switch_model_providers_from_path_core(db, &db_path).await +} + +async fn import_cc_switch_model_providers_from_path_core( + db: &AppDatabase, + db_path: &Path, + request: ImportCcSwitchModelProvidersRequest, +) -> Result { + let preview = list_importable_cc_switch_model_providers_from_path_core(db, db_path).await?; + if !preview.available { + return Ok(ImportCcSwitchModelProvidersResult { + imported_ids: Vec::new(), + skipped: Vec::new(), + }); + } + let wanted: std::collections::HashSet<&str> = + request.source_ids.iter().map(String::as_str).collect(); + let mut skipped: Vec = preview + .items + .iter() + .filter(|item| { + wanted.contains(item.source_id.as_str()) + && !item.importable + && !(request.overwrite_same_name + && item.skip_reason + == Some(crate::models::model_provider::CcSwitchModelProviderSkipReason::DuplicateName)) + }) + .cloned() + .collect(); + + let candidates = cc_switch_model_provider_import::load_cc_switch_import_candidates( + db_path, + &model_provider_service::list_all(&db.conn) + .await + .map_err(AppCommandError::from)?, + ) + .await?; + + let mut imported_ids = Vec::new(); + for candidate in candidates + .into_iter() + .filter(|candidate| wanted.contains(candidate.source_id.as_str())) + { + let current_existing = model_provider_service::list_all(&db.conn) + .await + .map_err(AppCommandError::from)?; + if let Some(reason) = cc_switch_model_provider_import::duplicate_reason_for_candidate( + &candidate, + ¤t_existing, + ) { + if request.overwrite_same_name + && reason + == crate::models::model_provider::CcSwitchModelProviderSkipReason::DuplicateName + { + if let Some(existing) = current_existing + .iter() + .find(|provider| provider.name == candidate.name) + { + if existing.agent_type == candidate.target_agent_type { + let updated = update_model_provider_core( + db, + existing.id, + None, + Some(candidate.api_url.clone()), + Some(candidate.api_key.clone()), + None, + Some(candidate.model.clone().unwrap_or_default()), + &EventEmitter::Noop, + ) + .await?; + imported_ids.push(updated.id); + continue; + } + } + } + + skipped.push(CcSwitchModelProviderPreviewItem { + source_id: candidate.source_id.clone(), + source_app_type: candidate.source_app_type.clone(), + target_agent_type: candidate.target_agent_type.clone(), + name: candidate.name.clone(), + api_url: Some(candidate.api_url.clone()), + model: candidate.model.clone(), + importable: false, + skip_reason: Some(reason), + }); + continue; + } + + let created = create_model_provider_core( + db, + candidate.name, + candidate.api_url, + candidate.api_key, + candidate.target_agent_type, + candidate.model, + ) + .await?; + imported_ids.push(created.id); + } + + Ok(ImportCcSwitchModelProvidersResult { + imported_ids: imported_ids, + skipped, + }) +} + +pub async fn import_cc_switch_model_providers_core( + db: &AppDatabase, + request: ImportCcSwitchModelProvidersRequest, +) -> Result { + let db_path = cc_switch_model_provider_import::resolve_cc_switch_db_path(); + import_cc_switch_model_providers_from_path_core(db, &db_path, request).await +} + /// Update a model provider. For the `model` parameter: /// - `None` (omitted) means "don't change" /// - `Some("")` means "clear" @@ -241,12 +389,13 @@ pub async fn update_model_provider_and_refresh( // Every agent bound to this provider may now be on stale config (the cascade // rewrote their env_json + native config files). Recompute and notify. - let agent_types: Vec = agent_setting_service::find_by_model_provider_id(&db.conn, id) - .await - .unwrap_or_default() - .iter() - .filter_map(|setting| serde_json::from_str(&setting.agent_type).ok()) - .collect(); + let agent_types: Vec = + agent_setting_service::find_by_model_provider_id(&db.conn, id) + .await + .unwrap_or_default() + .iter() + .filter_map(|setting| serde_json::from_str(&setting.agent_type).ok()) + .collect(); let affected_running_sessions = acp::refresh_config_staleness( manager, db, @@ -314,6 +463,23 @@ pub async fn create_model_provider( create_model_provider_core(&db, name, api_url, api_key, agent_type, model).await } +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_importable_cc_switch_model_providers( + db: tauri::State<'_, AppDatabase>, +) -> Result { + list_importable_cc_switch_model_providers_core(&db).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn import_cc_switch_model_providers( + db: tauri::State<'_, AppDatabase>, + request: ImportCcSwitchModelProvidersRequest, +) -> Result { + import_cc_switch_model_providers_core(&db, request).await +} + #[cfg(feature = "tauri-runtime")] #[tauri::command] #[allow(clippy::too_many_arguments)] @@ -363,6 +529,46 @@ pub async fn delete_model_provider( mod tests { use super::*; use crate::db::test_helpers::fresh_in_memory_db; + use sea_orm::{ConnectionTrait, DbBackend, Statement}; + use tempfile::tempdir; + + async fn seed_cc_switch_db( + providers: &[(&str, &str, &str, serde_json::Value)], + ) -> tempfile::TempDir { + let dir = tempdir().expect("temp dir"); + let db_path = dir.path().join("cc-switch.db"); + let conn = + sea_orm::Database::connect(format!("sqlite:{}?mode=rwc", db_path.to_string_lossy())) + .await + .expect("open cc-switch db"); + conn.execute(Statement::from_string( + DbBackend::Sqlite, + "CREATE TABLE providers (id TEXT PRIMARY KEY, app_type TEXT NOT NULL, name TEXT NOT NULL, settings_config TEXT);" + .to_owned(), + )) + .await + .expect("create providers table"); + + for (id, app_type, name, settings) in providers { + let settings_json = serde_json::to_string(settings).expect("serialize settings config"); + let escaped_name = name.replace('\'', "''"); + let escaped_settings = settings_json.replace('\'', "''"); + conn.execute(Statement::from_string( + DbBackend::Sqlite, + format!( + "INSERT INTO providers (id, app_type, name, settings_config) VALUES ('{}', '{}', '{}', '{}');", + id.replace('\'', "''"), + app_type.replace('\'', "''"), + escaped_name, + escaped_settings + ), + )) + .await + .expect("insert cc-switch provider row"); + } + + dir + } /// Regression: an `api_key` containing a multibyte character (e.g. a /// full-width char typed under a CJK IME) must not panic the masking in @@ -498,4 +704,226 @@ mod tests { "renaming the provider must not shift the agent fingerprint" ); } + + #[tokio::test] + async fn list_importable_cc_switch_model_providers_reports_missing_db_as_unavailable() { + let db = fresh_in_memory_db().await; + let dir = tempdir().expect("temp dir"); + let db_path = dir.path().join("missing.db"); + + let result = list_importable_cc_switch_model_providers_from_path_core(&db, &db_path) + .await + .expect("list preview"); + + assert!(!result.available); + assert!(result.items.is_empty()); + assert_eq!(result.source_path, db_path.display().to_string()); + } + + #[tokio::test] + async fn import_cc_switch_model_providers_imports_only_selected_rows() { + let db = fresh_in_memory_db().await; + let cc_switch_dir = seed_cc_switch_db(&[ + ( + "codex-1", + "codex", + "Codex One", + serde_json::json!({ + "auth": { "OPENAI_API_KEY": "sk-codex-1" }, + "config": "model = \"gpt-5\"\nbase_url = \"https://api.codex.one/v1\"\n" + }), + ), + ( + "gemini-1", + "gemini", + "Gemini One", + serde_json::json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://api.gemini.one", + "GEMINI_API_KEY": "gm-key-1", + "GEMINI_MODEL": "gemini-3-pro" + } + }), + ), + ]) + .await; + let db_path = cc_switch_dir.path().join("cc-switch.db"); + + let result = import_cc_switch_model_providers_from_path_core( + &db, + &db_path, + ImportCcSwitchModelProvidersRequest { + source_ids: vec!["codex:codex-1".to_string()], + overwrite_same_name: false, + }, + ) + .await + .expect("import selected rows"); + + assert_eq!(result.imported_ids.len(), 1); + assert!(result.skipped.is_empty()); + + let providers = list_model_providers_core(&db) + .await + .expect("list imported providers"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].name, "Codex One"); + assert_eq!(providers[0].agent_type, "codex"); + assert_eq!(providers[0].model.as_deref(), Some("gpt-5")); + } + + #[tokio::test] + async fn import_cc_switch_model_providers_rechecks_duplicates_between_selected_rows() { + let db = fresh_in_memory_db().await; + let cc_switch_dir = seed_cc_switch_db(&[ + ( + "codex-1", + "codex", + "Shared Name", + serde_json::json!({ + "auth": { "OPENAI_API_KEY": "sk-codex-1" }, + "config": "model = \"gpt-5\"\nbase_url = \"https://api.first.example/v1\"\n" + }), + ), + ( + "gemini-1", + "gemini", + "Shared Name", + serde_json::json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://api.second.example", + "GEMINI_API_KEY": "gm-key-1", + "GEMINI_MODEL": "gemini-3-pro" + } + }), + ), + ]) + .await; + let db_path = cc_switch_dir.path().join("cc-switch.db"); + + let result = import_cc_switch_model_providers_from_path_core( + &db, + &db_path, + ImportCcSwitchModelProvidersRequest { + source_ids: vec!["codex:codex-1".to_string(), "gemini:gemini-1".to_string()], + overwrite_same_name: false, + }, + ) + .await + .expect("import rows with internal duplicate"); + + assert_eq!(result.imported_ids.len(), 1); + assert_eq!(result.skipped.len(), 1); + assert_eq!( + result.skipped[0].skip_reason, + Some(crate::models::model_provider::CcSwitchModelProviderSkipReason::DuplicateName) + ); + + let providers = list_model_providers_core(&db) + .await + .expect("list imported providers"); + assert_eq!(providers.len(), 1); + } + + #[tokio::test] + async fn import_cc_switch_model_providers_overwrites_same_name_same_agent_type() { + let db = fresh_in_memory_db().await; + let existing = create_model_provider_core( + &db, + "Shared Name".to_string(), + "https://old.example.com/v1".to_string(), + "sk-old".to_string(), + "codex".to_string(), + Some("gpt-4.1".to_string()), + ) + .await + .expect("create existing provider"); + + let cc_switch_dir = seed_cc_switch_db(&[( + "codex-1", + "codex", + "Shared Name", + serde_json::json!({ + "auth": { "OPENAI_API_KEY": "sk-new" }, + "config": "model = \"gpt-5\"\nbase_url = \"https://new.example.com/v1\"\n" + }), + )]) + .await; + let db_path = cc_switch_dir.path().join("cc-switch.db"); + + let result = import_cc_switch_model_providers_from_path_core( + &db, + &db_path, + ImportCcSwitchModelProvidersRequest { + source_ids: vec!["codex:codex-1".to_string()], + overwrite_same_name: true, + }, + ) + .await + .expect("overwrite same-name provider"); + + assert_eq!(result.imported_ids, vec![existing.id]); + assert!(result.skipped.is_empty()); + + let providers = list_model_providers_core(&db) + .await + .expect("list providers after overwrite"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].id, existing.id); + assert_eq!(providers[0].api_url, "https://new.example.com/v1"); + assert_eq!(providers[0].api_key, "sk-new"); + assert_eq!(providers[0].model.as_deref(), Some("gpt-5")); + } + + #[tokio::test] + async fn import_cc_switch_model_providers_does_not_overwrite_same_name_different_agent_type() { + let db = fresh_in_memory_db().await; + create_model_provider_core( + &db, + "Shared Name".to_string(), + "https://existing.example.com/v1".to_string(), + "sk-existing".to_string(), + "gemini".to_string(), + Some("gemini-2.5-pro".to_string()), + ) + .await + .expect("create existing provider"); + + let cc_switch_dir = seed_cc_switch_db(&[( + "codex-1", + "codex", + "Shared Name", + serde_json::json!({ + "auth": { "OPENAI_API_KEY": "sk-new" }, + "config": "model = \"gpt-5\"\nbase_url = \"https://new.example.com/v1\"\n" + }), + )]) + .await; + let db_path = cc_switch_dir.path().join("cc-switch.db"); + + let result = import_cc_switch_model_providers_from_path_core( + &db, + &db_path, + ImportCcSwitchModelProvidersRequest { + source_ids: vec!["codex:codex-1".to_string()], + overwrite_same_name: true, + }, + ) + .await + .expect("attempt overwrite with mismatched agent type"); + + assert!(result.imported_ids.is_empty()); + assert_eq!(result.skipped.len(), 1); + assert_eq!( + result.skipped[0].skip_reason, + Some(crate::models::model_provider::CcSwitchModelProviderSkipReason::DuplicateName) + ); + + let providers = list_model_providers_core(&db) + .await + .expect("list providers after mismatch skip"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].agent_type, "gemini"); + assert_eq!(providers[0].api_url, "https://existing.example.com/v1"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd9df2632..91f0dac39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,16 +45,15 @@ mod tauri_app { use crate::acp::manager::ConnectionManager; use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ - acp as acp_commands, app_update as app_update_commands, - automation as automation_commands, backup, - chat_channel as chat_channel_commands, conversations, delegation as delegation_commands, - experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folders, logging as logging_commands, mcp as mcp_commands, + acp as acp_commands, app_update as app_update_commands, automation as automation_commands, + backup, chat_channel as chat_channel_commands, conversations, + delegation as delegation_commands, experts as experts_commands, + feedback as feedback_commands, file_io, folder_commands, folders, + logging as logging_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, - remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, session_info as session_info_commands, - system_settings, terminal as terminal_commands, + remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, + session_info as session_info_commands, system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; @@ -1123,6 +1122,8 @@ mod tauri_app { model_provider_commands::create_model_provider, model_provider_commands::update_model_provider, model_provider_commands::delete_model_provider, + model_provider_commands::list_importable_cc_switch_model_providers, + model_provider_commands::import_cc_switch_model_providers, web::start_web_server, web::stop_web_server, web::get_web_server_status, diff --git a/src-tauri/src/models/model_provider.rs b/src-tauri/src/models/model_provider.rs index 1d92ad116..53d701714 100644 --- a/src-tauri/src/models/model_provider.rs +++ b/src-tauri/src/models/model_provider.rs @@ -13,6 +13,55 @@ pub struct ModelProviderInfo { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CcSwitchModelProviderSkipReason { + UnsupportedAppType, + MissingName, + MissingApiUrl, + MissingApiKey, + InvalidModel, + DuplicateName, + DuplicateConfig, + MalformedSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CcSwitchModelProviderPreviewItem { + pub source_id: String, + pub source_app_type: String, + pub target_agent_type: String, + pub name: String, + pub api_url: Option, + pub model: Option, + pub importable: bool, + pub skip_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ListImportableCcSwitchModelProvidersResult { + pub available: bool, + pub source_path: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImportCcSwitchModelProvidersRequest { + pub source_ids: Vec, + #[serde(default)] + pub overwrite_same_name: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImportCcSwitchModelProvidersResult { + pub imported_ids: Vec, + pub skipped: Vec, +} + fn mask_api_key(key: &str) -> String { // Operate on Unicode scalar values, not bytes: an API key may contain a // multibyte character (e.g. a full-width char typed with a CJK IME), and @@ -57,7 +106,7 @@ impl From for ModelProviderInfo { #[cfg(test)] mod tests { - use super::mask_api_key; + use super::{mask_api_key, CcSwitchModelProviderPreviewItem, CcSwitchModelProviderSkipReason}; #[test] fn masks_short_ascii_key() { @@ -81,4 +130,25 @@ mod tests { fn masks_short_multibyte_key_without_panic() { assert_eq!(mask_api_key("密钥abc"), "\u{2022}".repeat(5)); } + + #[test] + fn cc_switch_preview_item_serializes_camel_case() { + let item = CcSwitchModelProviderPreviewItem { + source_id: "codex:demo".to_string(), + source_app_type: "codex".to_string(), + target_agent_type: "codex".to_string(), + name: "Demo".to_string(), + api_url: Some("https://api.example.com/v1".to_string()), + model: Some("gpt-5".to_string()), + importable: false, + skip_reason: Some(CcSwitchModelProviderSkipReason::DuplicateName), + }; + + let value = serde_json::to_value(item).expect("serialize preview item"); + assert_eq!(value["sourceId"], "codex:demo"); + assert_eq!(value["sourceAppType"], "codex"); + assert_eq!(value["targetAgentType"], "codex"); + assert_eq!(value["importable"], false); + assert_eq!(value["skipReason"], "duplicate_name"); + } } diff --git a/src-tauri/src/web/handlers/model_provider.rs b/src-tauri/src/web/handlers/model_provider.rs index d5fec5fb2..5bdc71c5c 100644 --- a/src-tauri/src/web/handlers/model_provider.rs +++ b/src-tauri/src/web/handlers/model_provider.rs @@ -6,7 +6,10 @@ use serde::Deserialize; use crate::app_error::AppCommandError; use crate::app_state::AppState; use crate::commands::model_provider as mp_commands; -use crate::models::model_provider::ModelProviderInfo; +use crate::models::model_provider::{ + ImportCcSwitchModelProvidersRequest, ImportCcSwitchModelProvidersResult, + ListImportableCcSwitchModelProvidersResult, ModelProviderInfo, +}; // --------------------------------------------------------------------------- // Param structs @@ -93,3 +96,18 @@ pub async fn delete_model_provider( mp_commands::delete_model_provider_core(&state.db, params.id).await?; Ok(Json(())) } + +pub async fn list_importable_cc_switch_model_providers( + Extension(state): Extension>, +) -> Result, AppCommandError> { + let result = mp_commands::list_importable_cc_switch_model_providers_core(&state.db).await?; + Ok(Json(result)) +} + +pub async fn import_cc_switch_model_providers( + Extension(state): Extension>, + Json(request): Json, +) -> Result, AppCommandError> { + let result = mp_commands::import_cc_switch_model_providers_core(&state.db, request).await?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index a088faa0f..d561f513b 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -886,6 +886,14 @@ pub fn build_router( "/delete_model_provider", post(handlers::model_provider::delete_model_provider), ) + .route( + "/list_importable_cc_switch_model_providers", + post(handlers::model_provider::list_importable_cc_switch_model_providers), + ) + .route( + "/import_cc_switch_model_providers", + post(handlers::model_provider::import_cc_switch_model_providers), + ) // ─── Quick Messages ─── .route( "/quick_messages_list", @@ -912,7 +920,10 @@ pub fn build_router( "/automation_list", post(handlers::automation::automation_list), ) - .route("/automation_get", post(handlers::automation::automation_get)) + .route( + "/automation_get", + post(handlers::automation::automation_get), + ) .route( "/automation_runs", post(handlers::automation::automation_runs), diff --git a/src/components/settings/cc-switch-model-provider-importer.test.tsx b/src/components/settings/cc-switch-model-provider-importer.test.tsx new file mode 100644 index 000000000..90aa489c7 --- /dev/null +++ b/src/components/settings/cc-switch-model-provider-importer.test.tsx @@ -0,0 +1,136 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/lib/api", () => ({ + importCcSwitchModelProviders: vi.fn(), + listImportableCcSwitchModelProviders: vi.fn(), +})) + +import { CcSwitchModelProviderImporter } from "./cc-switch-model-provider-importer" +import enMessages from "@/i18n/messages/en.json" +import { + importCcSwitchModelProviders, + listImportableCcSwitchModelProviders, +} from "@/lib/api" + +function renderImporter() { + return render( + + {})} + /> + + ) +} + +describe("CcSwitchModelProviderImporter", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it("renders unavailable state when the cc-switch database is missing", async () => { + vi.mocked(listImportableCcSwitchModelProviders).mockResolvedValueOnce({ + available: false, + sourcePath: "C:/Users/test/.cc-switch/cc-switch.db", + items: [], + }) + + renderImporter() + + expect( + await screen.findByText(/No cc-switch database was found/i) + ).toBeInTheDocument() + expect( + screen.getByText("C:/Users/test/.cc-switch/cc-switch.db") + ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Import selected" }) + ).toBeDisabled() + }) + + it("imports the currently selected importable rows and refreshes the preview", async () => { + const onDone = vi.fn(async () => {}) + vi.mocked(listImportableCcSwitchModelProviders) + .mockResolvedValueOnce({ + available: true, + sourcePath: "C:/Users/test/.cc-switch/cc-switch.db", + items: [ + { + sourceId: "codex:codex-1", + sourceAppType: "codex", + targetAgentType: "codex", + name: "Codex One", + apiUrl: "https://api.example.com/v1", + model: "gpt-5", + importable: true, + skipReason: null, + }, + { + sourceId: "gemini:gemini-1", + sourceAppType: "gemini", + targetAgentType: "gemini", + name: "Gemini Existing", + apiUrl: "https://api.gemini.example", + model: "gemini-3-pro", + importable: false, + skipReason: "duplicate_name", + }, + ], + }) + .mockResolvedValueOnce({ + available: true, + sourcePath: "C:/Users/test/.cc-switch/cc-switch.db", + items: [ + { + sourceId: "codex:codex-1", + sourceAppType: "codex", + targetAgentType: "codex", + name: "Codex One", + apiUrl: "https://api.example.com/v1", + model: "gpt-5", + importable: false, + skipReason: "duplicate_name", + }, + ], + }) + vi.mocked(importCcSwitchModelProviders).mockResolvedValueOnce({ + importedIds: [101], + skipped: [], + }) + + render( + + + + ) + + expect(await screen.findByText("Codex One")).toBeInTheDocument() + expect(screen.getByText("Already exists by name")).toBeInTheDocument() + + fireEvent.click( + screen.getByRole("checkbox", { name: "Overwrite same-name providers" }) + ) + fireEvent.click(screen.getByRole("button", { name: "Import selected" })) + + await waitFor(() => { + expect(importCcSwitchModelProviders).toHaveBeenCalledWith({ + sourceIds: ["codex:codex-1"], + overwriteSameName: true, + }) + }) + await waitFor(() => { + expect(onDone).toHaveBeenCalledTimes(1) + }) + expect( + await screen.findByText("Imported 1 provider(s), skipped 0.") + ).toBeInTheDocument() + await waitFor(() => { + expect( + listImportableCcSwitchModelProviders.mock.calls.length + ).toBeGreaterThanOrEqual(2) + }) + }) +}) diff --git a/src/components/settings/cc-switch-model-provider-importer.tsx b/src/components/settings/cc-switch-model-provider-importer.tsx new file mode 100644 index 000000000..bc4bd0ec6 --- /dev/null +++ b/src/components/settings/cc-switch-model-provider-importer.tsx @@ -0,0 +1,360 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Download, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + importCcSwitchModelProviders, + listImportableCcSwitchModelProviders, +} from "@/lib/api" +import { toErrorMessage } from "@/lib/app-error" +import { + AGENT_LABELS, + type CcSwitchModelProviderPreviewItem, + type CcSwitchModelProviderSkipReason, +} from "@/lib/types" + +interface CcSwitchModelProviderImporterProps { + open: boolean + onClose: () => void + onDone: () => Promise | void +} + +interface ImportSummary { + imported: number + skipped: number +} + +const SOURCE_APP_LABELS: Record = { + claude: "Claude", + codex: "Codex", + gemini: "Gemini", + opencode: "OpenCode", + openclaw: "OpenClaw", + hermes: "Hermes", +} + +export function CcSwitchModelProviderImporter({ + open, + onClose, + onDone, +}: CcSwitchModelProviderImporterProps) { + const t = useTranslations("ModelProviderSettings.ccSwitchImport") + const [items, setItems] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [sourcePath, setSourcePath] = useState("") + const [available, setAvailable] = useState(false) + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [summary, setSummary] = useState(null) + const [overwriteSameName, setOverwriteSameName] = useState(false) + + const selectableIds = useMemo( + () => + items + .filter( + (item) => + item.importable || + (overwriteSameName && item.skipReason === "duplicate_name") + ) + .map((item) => item.sourceId), + [items, overwriteSameName] + ) + + const refresh = useCallback( + async (options?: { preserveSummary?: boolean }) => { + setLoading(true) + setError(null) + if (!options?.preserveSummary) { + setSummary(null) + } + try { + const result = await listImportableCcSwitchModelProviders() + setItems(result.items) + setAvailable(result.available) + setSourcePath(result.sourcePath) + setSelected( + new Set( + result.items + .filter((item) => item.importable) + .map((item) => item.sourceId) + ) + ) + setOverwriteSameName(false) + } catch (err) { + setError(toErrorMessage(err)) + } finally { + setLoading(false) + } + }, + [] + ) + + useEffect(() => { + if (open) { + void refresh() + } + }, [open, refresh]) + + const toggle = (sourceId: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(sourceId)) next.delete(sourceId) + else next.add(sourceId) + return next + }) + } + + const toggleAll = () => { + setSelected((prev) => { + if (prev.size === selectableIds.length) return new Set() + return new Set(selectableIds) + }) + } + + const handleImport = async () => { + setSubmitting(true) + setError(null) + try { + const result = await importCcSwitchModelProviders({ + sourceIds: Array.from(selected), + overwriteSameName, + }) + setSummary({ + imported: result.importedIds.length, + skipped: result.skipped.length, + }) + await onDone() + await refresh({ preserveSummary: true }) + } catch (err) { + setError(toErrorMessage(err)) + } finally { + setSubmitting(false) + } + } + + useEffect(() => { + setSelected((prev) => { + const next = new Set( + Array.from(prev).filter((sourceId) => selectableIds.includes(sourceId)) + ) + if (next.size > 0 || selectableIds.length === 0) { + return next + } + return new Set(selectableIds) + }) + }, [selectableIds]) + + const selectedCount = selected.size + + return ( + !nextOpen && onClose()}> + + + {t("title")} + {t("description")} + + +
+
+
+ {t("sourcePathLabel")} +
+
+ {sourcePath || t("sourcePathPending")} +
+
+ + {loading ? ( +
+ +
+ ) : !available ? ( +
+ {t("sourceMissing")} +
+ ) : items.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( + <> +
+
+ {t("summary", { + total: items.length, + importable: selectableIds.length, + skipped: items.length - selectableIds.length, + })} +
+
+ + +
+
+ +
+ {t("overwriteSameNameHint")} +
+ +
+ {items.map((item) => { + const checked = selected.has(item.sourceId) + const selectable = + item.importable || + (overwriteSameName && item.skipReason === "duplicate_name") + const disabled = !selectable || submitting + return ( + + ) + })} +
+ + )} + + {error ? ( +
+ {error} +
+ ) : null} + {summary ? ( +
+ {t("result", { + imported: summary.imported, + skipped: summary.skipped, + })} +
+ ) : null} +
+ + + + + +
+
+ ) +} + +function skipReasonLabel( + t: ReturnType, + reason: CcSwitchModelProviderSkipReason +): string { + switch (reason) { + case "unsupported_app_type": + return t("skipReasons.unsupportedAppType") + case "missing_name": + return t("skipReasons.missingName") + case "missing_api_url": + return t("skipReasons.missingApiUrl") + case "missing_api_key": + return t("skipReasons.missingApiKey") + case "invalid_model": + return t("skipReasons.invalidModel") + case "duplicate_name": + return t("skipReasons.duplicateName") + case "duplicate_config": + return t("skipReasons.duplicateConfig") + case "malformed_source": + return t("skipReasons.malformedSource") + } +} diff --git a/src/components/settings/model-provider-settings.tsx b/src/components/settings/model-provider-settings.tsx index 39714d6d6..52b6266b0 100644 --- a/src/components/settings/model-provider-settings.tsx +++ b/src/components/settings/model-provider-settings.tsx @@ -1,7 +1,14 @@ "use client" import { useCallback, useEffect, useMemo, useState } from "react" -import { Loader2, Pencil, Plus, Server, Trash2 } from "lucide-react" +import { + ArrowDownToLine, + Loader2, + Pencil, + Plus, + Server, + Trash2, +} from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -33,6 +40,7 @@ import { type ModelProviderInfo, } from "@/lib/types" import { AddModelProviderDialog } from "./add-model-provider-dialog" +import { CcSwitchModelProviderImporter } from "./cc-switch-model-provider-importer" import { EditModelProviderDialog } from "./edit-model-provider-dialog" export function ModelProviderSettings() { @@ -41,6 +49,7 @@ export function ModelProviderSettings() { const [loading, setLoading] = useState(true) const [filter, setFilter] = useState(null) const [addDialogOpen, setAddDialogOpen] = useState(false) + const [importDialogOpen, setImportDialogOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState( null @@ -122,14 +131,25 @@ export function ModelProviderSettings() { ))} - +
+ + +
{loading ? ( @@ -194,6 +214,12 @@ export function ModelProviderSettings() { onProviderAdded={loadProviders} /> + setImportDialogOpen(false)} + onDone={loadProviders} + /> + { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b9da5ec24..a394a4aaf 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2557,6 +2557,35 @@ "filterAll": "الكل", "providerListTitle": "المزودون المُعدّون", "addProvider": "إضافة مزود", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "تعديل المزود", "noProviders": "لم يتم تكوين أي مزود نماذج بعد.", "providerName": "الاسم", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9040c4816..c59dbfa5a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2557,6 +2557,35 @@ "filterAll": "Alle", "providerListTitle": "Konfigurierte Anbieter", "addProvider": "Anbieter hinzufügen", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "Anbieter bearbeiten", "noProviders": "Noch keine Modellanbieter konfiguriert.", "providerName": "Name", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index a3ed7f9fa..6d1e26893 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2569,6 +2569,35 @@ "filterAll": "All", "providerListTitle": "Configured Providers", "addProvider": "Add Provider", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "Edit Provider", "noProviders": "No model providers configured yet.", "providerName": "Name", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 3b5f3fd7f..4e45a3401 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2557,6 +2557,35 @@ "filterAll": "Todos", "providerListTitle": "Proveedores Configurados", "addProvider": "Agregar Proveedor", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "Editar Proveedor", "noProviders": "Aún no se han configurado proveedores de modelos.", "providerName": "Nombre", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 3b9ba72e2..34695c16c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2557,6 +2557,35 @@ "filterAll": "Tous", "providerListTitle": "Fournisseurs Configurés", "addProvider": "Ajouter un Fournisseur", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "Modifier le Fournisseur", "noProviders": "Aucun fournisseur de modèle configuré.", "providerName": "Nom", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 0277db18e..a6215db2b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2557,6 +2557,35 @@ "filterAll": "すべて", "providerListTitle": "設定済みプロバイダー", "addProvider": "プロバイダーを追加", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "プロバイダーを編集", "noProviders": "モデルプロバイダーはまだ設定されていません。", "providerName": "名前", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b95fff0b7..85a888084 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2557,6 +2557,35 @@ "filterAll": "전체", "providerListTitle": "구성된 제공업체", "addProvider": "제공업체 추가", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "제공업체 편집", "noProviders": "아직 구성된 모델 제공업체가 없습니다.", "providerName": "이름", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c19ad6a7a..9fac401e5 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2557,6 +2557,35 @@ "filterAll": "Todos", "providerListTitle": "Provedores Configurados", "addProvider": "Adicionar Provedor", + "ccSwitchImport": { + "open": "Import from cc-switch", + "title": "Import Model Providers from cc-switch", + "description": "Preview supported providers from your local cc-switch database before importing them into codeg.", + "sourcePathLabel": "Source database", + "sourcePathPending": "Loading source path…", + "sourceMissing": "No cc-switch database was found at the expected path.", + "empty": "No supported model providers were found in cc-switch.", + "summary": "{total} total, {importable} importable, {skipped} skipped", + "overwriteSameName": "Overwrite same-name providers", + "overwriteSameNameHint": "When enabled, selected duplicate-name rows will update an existing provider in place only if the existing row has the same agent type.", + "selectAll": "Select importable", + "clearSelection": "Clear selection", + "missingApiUrl": "Missing API URL", + "missingModel": "No model", + "result": "Imported {imported} provider(s), skipped {skipped}.", + "close": "Close", + "importSelected": "Import selected", + "skipReasons": { + "unsupportedAppType": "Unsupported app type", + "missingName": "Missing name", + "missingApiUrl": "Missing API URL", + "missingApiKey": "Missing API key", + "invalidModel": "Invalid model", + "duplicateName": "Already exists by name", + "duplicateConfig": "Already exists by config", + "malformedSource": "Malformed source data" + } + }, "editProvider": "Editar Provedor", "noProviders": "Nenhum provedor de modelo configurado.", "providerName": "Nome", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index bcad4373d..e196f11a9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2569,6 +2569,35 @@ "filterAll": "全部", "providerListTitle": "已配置的供应商", "addProvider": "添加供应商", + "ccSwitchImport": { + "open": "从 cc-switch 导入", + "title": "从 cc-switch 导入模型供应商", + "description": "先预览本地 cc-switch 数据库中可支持的供应商,再导入到 codeg。", + "sourcePathLabel": "源数据库", + "sourcePathPending": "正在加载源路径…", + "sourceMissing": "在预期路径下未找到 cc-switch 数据库。", + "empty": "cc-switch 中没有找到可支持的模型供应商。", + "summary": "共 {total} 项,可导入 {importable} 项,跳过 {skipped} 项", + "overwriteSameName": "覆盖同名供应商", + "overwriteSameNameHint": "开启后,所选的重名记录仅会在现有记录与导入记录 agent type 相同的情况下原地更新。", + "selectAll": "选择可导入项", + "clearSelection": "清空选择", + "missingApiUrl": "缺少 API URL", + "missingModel": "无模型", + "result": "已导入 {imported} 个供应商,跳过 {skipped} 个。", + "close": "关闭", + "importSelected": "导入所选", + "skipReasons": { + "unsupportedAppType": "不支持的应用类型", + "missingName": "缺少名称", + "missingApiUrl": "缺少 API URL", + "missingApiKey": "缺少 API Key", + "invalidModel": "模型无效", + "duplicateName": "名称已存在", + "duplicateConfig": "配置已存在", + "malformedSource": "源数据格式错误" + } + }, "editProvider": "编辑供应商", "noProviders": "尚未配置模型供应商。", "providerName": "名称", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index fa5dfcb9f..47fd37376 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2557,6 +2557,35 @@ "filterAll": "全部", "providerListTitle": "已配置的供應商", "addProvider": "新增供應商", + "ccSwitchImport": { + "open": "從 cc-switch 匯入", + "title": "從 cc-switch 匯入模型供應商", + "description": "先預覽本機 cc-switch 資料庫中可支援的供應商,再匯入到 codeg。", + "sourcePathLabel": "來源資料庫", + "sourcePathPending": "正在載入來源路徑…", + "sourceMissing": "在預期路徑下找不到 cc-switch 資料庫。", + "empty": "在 cc-switch 中找不到可支援的模型供應商。", + "summary": "共 {total} 項,可匯入 {importable} 項,略過 {skipped} 項", + "overwriteSameName": "覆蓋同名供應商", + "overwriteSameNameHint": "啟用後,所選的重名記錄只有在既有記錄與匯入記錄的 agent type 相同時才會原地更新。", + "selectAll": "選取可匯入項", + "clearSelection": "清除選取", + "missingApiUrl": "缺少 API URL", + "missingModel": "無模型", + "result": "已匯入 {imported} 個供應商,略過 {skipped} 個。", + "close": "關閉", + "importSelected": "匯入所選", + "skipReasons": { + "unsupportedAppType": "不支援的應用類型", + "missingName": "缺少名稱", + "missingApiUrl": "缺少 API URL", + "missingApiKey": "缺少 API Key", + "invalidModel": "模型無效", + "duplicateName": "名稱已存在", + "duplicateConfig": "設定已存在", + "malformedSource": "來源資料格式錯誤" + } + }, "editProvider": "編輯供應商", "noProviders": "尚未配置模型供應商。", "providerName": "名稱", diff --git a/src/lib/api.ts b/src/lib/api.ts index c7924afb9..57874abef 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -100,6 +100,9 @@ import type { ChatChannelMessageLog, WebhookConfig, ModelProviderInfo, + ListImportableCcSwitchModelProvidersResult, + ImportCcSwitchModelProvidersRequest, + ImportCcSwitchModelProvidersResult, UpdateModelProviderResult, PluginCheckSummary, QuickMessage, @@ -2842,6 +2845,18 @@ export async function deleteModelProvider(id: number): Promise { return getTransport().call("delete_model_provider", { id }) } +export async function listImportableCcSwitchModelProviders(): Promise { + return getTransport().call("list_importable_cc_switch_model_providers") +} + +export async function importCcSwitchModelProviders( + request: ImportCcSwitchModelProvidersRequest +): Promise { + return getTransport().call("import_cc_switch_model_providers", { + ...request, + }) +} + // ─── Delegation settings ─────────────────────────────────────────────── export interface DelegationSettings { diff --git a/src/lib/cc-switch-model-provider-import-types.test.ts b/src/lib/cc-switch-model-provider-import-types.test.ts new file mode 100644 index 000000000..e87a239c5 --- /dev/null +++ b/src/lib/cc-switch-model-provider-import-types.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" + +import type { + CcSwitchModelProviderPreviewItem, + ImportCcSwitchModelProvidersRequest, +} from "@/lib/types" + +describe("cc-switch model provider import transport types", () => { + it("accepts preview items and import requests", () => { + const item: CcSwitchModelProviderPreviewItem = { + sourceId: "codex:demo", + sourceAppType: "codex", + targetAgentType: "codex", + name: "Demo", + apiUrl: "https://api.example.com/v1", + model: "gpt-5", + importable: true, + skipReason: null, + } + const request: ImportCcSwitchModelProvidersRequest = { + sourceIds: [item.sourceId], + } + + expect(request.sourceIds).toEqual(["codex:demo"]) + }) +}) diff --git a/src/lib/types.ts b/src/lib/types.ts index 2c38c756e..36b69dd1b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2223,3 +2223,42 @@ export function serializeClaudeProviderModel( cleaned.customOptionDescription = obj.customOptionDescription.trim() return Object.keys(cleaned).length === 0 ? null : JSON.stringify(cleaned) } + +// --- cc-switch import types --- + +export type CcSwitchModelProviderSkipReason = + | "unsupported_app_type" + | "missing_name" + | "missing_api_url" + | "missing_api_key" + | "invalid_model" + | "duplicate_name" + | "duplicate_config" + | "malformed_source" + +export interface CcSwitchModelProviderPreviewItem { + sourceId: string + sourceAppType: string + targetAgentType: AgentType + name: string + apiUrl: string | null + model: string | null + importable: boolean + skipReason: CcSwitchModelProviderSkipReason | null +} + +export interface ListImportableCcSwitchModelProvidersResult { + available: boolean + sourcePath: string + items: CcSwitchModelProviderPreviewItem[] +} + +export interface ImportCcSwitchModelProvidersRequest { + sourceIds: string[] + overwriteSameName?: boolean +} + +export interface ImportCcSwitchModelProvidersResult { + importedIds: number[] + skipped: CcSwitchModelProviderPreviewItem[] +}