//! Provider import from deep link //! //! Handles importing provider configurations via ccswitch:// URLs. use super::utils::{decode_base64_param, infer_homepage_from_endpoint}; use super::DeepLinkImportRequest; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta, UsageScript}; use crate::services::ProviderService; use crate::store::AppState; use crate::AppType; use serde_json::json; use std::str::FromStr; /// Import a provider from a deep link request /// /// This function: /// 1. Validates the request /// 2. Merges config file if provided (v3.8+) /// 3. Converts it to a Provider structure /// 4. Delegates to ProviderService for actual import /// 5. Optionally sets as current provider if enabled=true pub fn import_provider_from_deeplink( state: &AppState, request: DeepLinkImportRequest, ) -> Result { // Verify this is a provider request if request.resource != "provider" { return Err(AppError::InvalidInput(format!( "Expected provider resource, got '{}'", request.resource ))); } // Step 1: Merge config file if provided (v3.8+) let mut merged_request = parse_and_merge_config(&request)?; // Extract required fields (now as Option) let app_str = merged_request .app .clone() .ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?; let api_key = merged_request.api_key.as_ref().ok_or_else(|| { AppError::InvalidInput("API key is required (either in URL or config file)".to_string()) })?; if api_key.is_empty() { return Err(AppError::InvalidInput( "API key cannot be empty".to_string(), )); } // Get endpoint: supports comma-separated multiple URLs (first is primary) let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| { AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string()) })?; // Parse endpoints: split by comma, first is primary let all_endpoints: Vec = endpoint_str .split(',') .map(|e| e.trim().to_string()) .filter(|e| !e.is_empty()) .collect(); let primary_endpoint = all_endpoints .first() .ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?; // Auto-infer homepage from endpoint if not provided if merged_request .homepage .as_ref() .is_none_or(|s| s.is_empty()) { merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint); } let homepage = merged_request.homepage.as_ref().ok_or_else(|| { AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string()) })?; if homepage.is_empty() { return Err(AppError::InvalidInput( "Homepage cannot be empty".to_string(), )); } let name = merged_request .name .clone() .ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?; // Parse app type let app_type = AppType::from_str(&app_str) .map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?; // Build provider configuration based on app type let mut provider = build_provider_from_request(&app_type, &merged_request)?; // Generate a unique ID for the provider using timestamp + sanitized name let timestamp = chrono::Utc::now().timestamp_millis(); let sanitized_name = name .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect::() .to_lowercase(); provider.id = format!("{sanitized_name}-{timestamp}"); let provider_id = provider.id.clone(); // Use ProviderService to add the provider ProviderService::add(state, app_type.clone(), provider)?; // Add extra endpoints as custom endpoints (skip first one as it's the primary) for ep in all_endpoints.iter().skip(1) { let normalized = ep.trim().trim_end_matches('/').to_string(); if !normalized.is_empty() { if let Err(e) = ProviderService::add_custom_endpoint( state, app_type.clone(), &provider_id, normalized.clone(), ) { log::warn!("Failed to add custom endpoint '{normalized}': {e}"); } } } // If enabled=true, set as current provider if merged_request.enabled.unwrap_or(false) { ProviderService::switch(state, app_type.clone(), &provider_id)?; log::info!("Provider '{provider_id}' set as current for {app_type:?}"); } Ok(provider_id) } /// Build a Provider structure from a deep link request pub(crate) fn build_provider_from_request( app_type: &AppType, request: &DeepLinkImportRequest, ) -> Result { let settings_config = match app_type { AppType::Claude => build_claude_settings(request), AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), }; // Build usage script configuration if provided let meta = build_provider_meta(request)?; let provider = Provider { id: String::new(), // Will be generated by caller name: request.name.clone().unwrap_or_default(), settings_config, website_url: request.homepage.clone(), category: None, created_at: None, sort_index: None, notes: request.notes.clone(), meta, icon: request.icon.clone(), icon_color: None, in_failover_queue: false, }; Ok(provider) } /// Get primary endpoint from request (first one if comma-separated) fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String { request .endpoint .as_ref() .and_then(|ep| ep.split(',').next()) .map(|s| s.trim().to_string()) .unwrap_or_default() } /// Build provider meta with usage script configuration fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, AppError> { // Check if any usage script fields are provided if request.usage_script.is_none() && request.usage_enabled.is_none() && request.usage_api_key.is_none() && request.usage_base_url.is_none() && request.usage_access_token.is_none() && request.usage_user_id.is_none() && request.usage_auto_interval.is_none() { return Ok(None); } // Decode usage script code if provided let code = if let Some(script_b64) = &request.usage_script { let decoded = decode_base64_param("usage_script", script_b64)?; String::from_utf8(decoded) .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))? } else { String::new() }; // Determine enabled state: explicit param > has code > false let enabled = request.usage_enabled.unwrap_or(!code.is_empty()); // Build UsageScript - use provider's API key and endpoint as defaults // Note: use primary endpoint only (first one if comma-separated) let usage_script = UsageScript { enabled, language: "javascript".to_string(), code, timeout: Some(10), api_key: request .usage_api_key .clone() .or_else(|| request.api_key.clone()), base_url: request.usage_base_url.clone().or_else(|| { let primary = get_primary_endpoint(request); if primary.is_empty() { None } else { Some(primary) } }), access_token: request.usage_access_token.clone(), user_id: request.usage_user_id.clone(), template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic) auto_query_interval: request.usage_auto_interval, }; Ok(Some(ProviderMeta { usage_script: Some(usage_script), ..Default::default() })) } /// Build Claude settings configuration fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let mut env = serde_json::Map::new(); env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), json!(request.api_key.clone().unwrap_or_default()), ); env.insert( "ANTHROPIC_BASE_URL".to_string(), json!(get_primary_endpoint(request)), ); // Add default model if provided if let Some(model) = &request.model { env.insert("ANTHROPIC_MODEL".to_string(), json!(model)); } // Add Claude-specific model fields (v3.7.1+) if let Some(haiku_model) = &request.haiku_model { env.insert( "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), json!(haiku_model), ); } if let Some(sonnet_model) = &request.sonnet_model { env.insert( "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), json!(sonnet_model), ); } if let Some(opus_model) = &request.opus_model { env.insert( "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), json!(opus_model), ); } json!({ "env": env }) } /// Build Codex settings configuration fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value { // Generate a safe provider name identifier let clean_provider_name = { let raw: String = request .name .clone() .unwrap_or_else(|| "custom".to_string()) .chars() .filter(|c| !c.is_control()) .collect(); let lower = raw.to_lowercase(); let mut key: String = lower .chars() .map(|c| match c { 'a'..='z' | '0'..='9' | '_' => c, _ => '_', }) .collect(); // Remove leading/trailing underscores while key.starts_with('_') { key.remove(0); } while key.ends_with('_') { key.pop(); } if key.is_empty() { "custom".to_string() } else { key } }; // Model name: use deeplink model or default let model_name = request .model .as_deref() .unwrap_or("gpt-5-codex") .to_string(); // Endpoint: normalize trailing slashes (use primary endpoint only) let endpoint = get_primary_endpoint(request) .trim() .trim_end_matches('/') .to_string(); // Build config.toml content let config_toml = format!( r#"model_provider = "{clean_provider_name}" model = "{model_name}" model_reasoning_effort = "high" disable_response_storage = true [model_providers.{clean_provider_name}] name = "{clean_provider_name}" base_url = "{endpoint}" wire_api = "responses" requires_openai_auth = true "# ); json!({ "auth": { "OPENAI_API_KEY": request.api_key, }, "config": config_toml }) } /// Build Gemini settings configuration fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let mut env = serde_json::Map::new(); env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key)); env.insert( "GOOGLE_GEMINI_BASE_URL".to_string(), json!(get_primary_endpoint(request)), ); // Add model if provided if let Some(model) = &request.model { env.insert("GEMINI_MODEL".to_string(), json!(model)); } json!({ "env": env }) } /// Build OpenCode settings configuration fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value { let endpoint = get_primary_endpoint(request); // Build options object let mut options = serde_json::Map::new(); if !endpoint.is_empty() { options.insert("baseURL".to_string(), json!(endpoint)); } if let Some(api_key) = &request.api_key { options.insert("apiKey".to_string(), json!(api_key)); } // Build models object let mut models = serde_json::Map::new(); if let Some(model) = &request.model { models.insert(model.clone(), json!({ "name": model })); } // Default to openai-compatible npm package json!({ "npm": "@ai-sdk/openai-compatible", "options": options, "models": models }) } // ============================================================================= // Config Merge Logic // ============================================================================= /// Parse and merge configuration from Base64 encoded config or remote URL /// /// Priority: URL params > inline config > remote config pub fn parse_and_merge_config( request: &DeepLinkImportRequest, ) -> Result { // If no config provided, return original request if request.config.is_none() && request.config_url.is_none() { return Ok(request.clone()); } // Step 1: Get config content let config_content = if let Some(config_b64) = &request.config { // Decode Base64 inline config let decoded = decode_base64_param("config", config_b64)?; String::from_utf8(decoded) .map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))? } else if let Some(_config_url) = &request.config_url { // Fetch remote config (TODO: implement remote fetching in next phase) return Err(AppError::InvalidInput( "Remote config URL is not yet supported. Use inline config instead.".to_string(), )); } else { return Ok(request.clone()); }; // Step 2: Parse config based on format let format = request.config_format.as_deref().unwrap_or("json"); let config_value: serde_json::Value = match format { "json" => serde_json::from_str(&config_content) .map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?, "toml" => { let toml_value: toml::Value = toml::from_str(&config_content) .map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?; // Convert TOML to JSON for uniform processing serde_json::to_value(toml_value) .map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))? } _ => { return Err(AppError::InvalidInput(format!( "Unsupported config format: {format}" ))) } }; // Step 3: Extract values from config based on app type and merge with URL params let mut merged = request.clone(); // MCP, Skill and other resource types don't need config merging if request.resource != "provider" { return Ok(merged); } match request.app.as_deref().unwrap_or("") { "claude" => merge_claude_config(&mut merged, &config_value)?, "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, "" => { // No app specified, skip merging return Ok(merged); } _ => { return Err(AppError::InvalidInput(format!( "Invalid app type: {:?}", request.app ))) } } Ok(merged) } /// Merge Claude configuration from config file fn merge_claude_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, ) -> Result<(), AppError> { let env = config .get("env") .and_then(|v| v.as_object()) .ok_or_else(|| { AppError::InvalidInput("Claude config must have 'env' object".to_string()) })?; // Auto-fill API key if not provided in URL if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { request.api_key = Some(token.to_string()); } } // Auto-fill endpoint if not provided in URL if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { request.endpoint = Some(base_url.to_string()); } } // Auto-fill homepage from endpoint if not provided if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { request.homepage = infer_homepage_from_endpoint(endpoint); if request.homepage.is_none() { request.homepage = Some("https://anthropic.com".to_string()); } } } // Auto-fill model fields (URL params take priority) if request.model.is_none() { request.model = env .get("ANTHROPIC_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if request.haiku_model.is_none() { request.haiku_model = env .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if request.sonnet_model.is_none() { request.sonnet_model = env .get("ANTHROPIC_DEFAULT_SONNET_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if request.opus_model.is_none() { request.opus_model = env .get("ANTHROPIC_DEFAULT_OPUS_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } Ok(()) } /// Merge Codex configuration from config file fn merge_codex_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, ) -> Result<(), AppError> { // Auto-fill API key from auth.OPENAI_API_KEY if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(api_key) = config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) .and_then(|v| v.as_str()) { request.api_key = Some(api_key.to_string()); } } // Auto-fill endpoint and model from config string if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) { // Parse TOML config string to extract base_url and model if let Ok(toml_value) = toml::from_str::(config_str) { // Extract base_url from model_providers section if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = extract_codex_base_url(&toml_value) { request.endpoint = Some(base_url); } } // Extract model if request.model.is_none() { if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) { request.model = Some(model.to_string()); } } } } // Auto-fill homepage from endpoint if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { request.homepage = infer_homepage_from_endpoint(endpoint); if request.homepage.is_none() { request.homepage = Some("https://openai.com".to_string()); } } } Ok(()) } /// Merge Gemini configuration from config file fn merge_gemini_config( request: &mut DeepLinkImportRequest, config: &serde_json::Value, ) -> Result<(), AppError> { // Gemini uses flat env structure if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { request.api_key = Some(api_key.to_string()); } } if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = config .get("GOOGLE_GEMINI_BASE_URL") .or_else(|| config.get("GEMINI_BASE_URL")) .and_then(|v| v.as_str()) { request.endpoint = Some(base_url.to_string()); } } if request.model.is_none() { request.model = config .get("GEMINI_MODEL") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } // Auto-fill homepage from endpoint if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { request.homepage = infer_homepage_from_endpoint(endpoint); if request.homepage.is_none() { request.homepage = Some("https://ai.google.dev".to_string()); } } } Ok(()) } /// Extract base_url from Codex TOML config fn extract_codex_base_url(toml_value: &toml::Value) -> Option { // Try to find base_url in model_providers section if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) { for (_key, provider) in providers.iter() { if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) { return Some(base_url.to_string()); } } } None }