{"repo": "chatman-media/timeline-studio", "files": {"crates/ts-security/src/env_importer.rs": {"9253fded22485cb087ef100842c6be6a8989dfc4": {"t": 1785745830.9859872, "n": 26603, "h": "464bff60936adf12", "c": "use super::{ApiKeyData, ApiKeyType, OAuthCredentials};\nuse anyhow::{Context, Result};\nuse std::collections::HashMap;\nuse std::env;\nuse std::fs;\nuse std::path::Path;\n\n/// Импортер для загрузки API ключей из .env файлов\npub struct EnvImporter {\n env_file_path: Option,\n}\n\nimpl EnvImporter {\n /// Создает новый импортер\n pub fn new() -> Self {\n Self {\n env_file_path: None,\n }\n }\n\n /// Создает импортер с указанным путем к .env файлу\n pub fn with_env_file>(env_file_path: P) -> Self {\n Self {\n env_file_path: Some(env_file_path.as_ref().to_string_lossy().to_string()),\n }\n }\n\n /// Загружает .env файл если указан путь\n pub fn load_env_file(&self) -> Result<()> {\n if let Some(env_path) = &self.env_file_path {\n if Path::new(env_path).exists() {\n let content = fs::read_to_string(env_path).context(\"Failed to read .env file\")?;\n\n for line in content.lines() {\n let line = line.trim();\n if line.is_empty() || line.starts_with('#') {\n continue;\n }\n\n if let Some((key, value)) = line.split_once('=') {\n let key = key.trim();\n let value = value.trim().trim_matches('\"').trim_matches('\\'');\n unsafe { env::set_var(key, value) };\n }\n }\n\n log::info!(\"Loaded .env file: {env_path}\");\n } else {\n log::warn!(\".env file not found: {env_path}\");\n }\n }\n\n Ok(())\n }\n\n /// Импортирует все доступные API ключи из переменных окружения\n pub fn import_all_api_keys(&self) -> Result> {\n // Сначала загружаем .env файл если указан\n self.load_env_file()?;\n\n let mut imported_keys = Vec::new();\n\n // Карта переменных окружения для каждого типа ключа\n let env_mappings = self.get_env_mappings();\n\n for (key_type, env_vars) in env_mappings {\n if let Some(key_data) = self.import_single_api_key(key_type, &env_vars)? {\n imported_keys.push(key_data);\n }\n }\n\n log::info!(\"Imported {} API keys from environment\", imported_keys.len());\n Ok(imported_keys)\n }\n\n /// Импортирует конкретный API ключ\n #[allow(dead_code)]\n pub fn import_api_key(&self, key_type: ApiKeyType) -> Result> {\n self.load_env_file()?;\n\n let env_mappings = self.get_env_mappings();\n if let Some(env_vars) = env_mappings.get(&key_type) {\n self.import_single_api_key(key_type, env_vars)\n } else {\n Ok(None)\n }\n }\n\n /// Получает карту переменных окружения для каждого типа ключа\n fn get_env_mappings(&self) -> HashMap> {\n let mut mappings = HashMap::new();\n\n // AI сервисы\n mappings.insert(\n ApiKeyType::OpenAI,\n vec![\"OPENAI_API_KEY\".to_string(), \"OPENAI_KEY\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::Claude,\n vec![\n \"CLAUDE_API_KEY\".to_string(),\n \"ANTHROPIC_API_KEY\".to_string(),\n \"CLAUDE_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::MCPClaude,\n vec![\n \"MCP_CLAUDE_API_KEY\".to_string(),\n \"MCP_ANTHROPIC_API_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::DeepSeek,\n vec![\"DEEPSEEK_API_KEY\".to_string(), \"DEEPSEEK_KEY\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::Grok,\n vec![\"GROK_API_KEY\".to_string(), \"GROK_KEY\".to_string()],\n );\n\n // Социальные сети - OAuth credentials\n mappings.insert(\n ApiKeyType::YouTube,\n vec![\n \"YOUTUBE_CLIENT_ID\".to_string(),\n \"YOUTUBE_CLIENT_SECRET\".to_string(),\n \"GOOGLE_CLIENT_ID\".to_string(),\n \"GOOGLE_CLIENT_SECRET\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::TikTok,\n vec![\n \"TIKTOK_CLIENT_ID\".to_string(),\n \"TIKTOK_CLIENT_SECRET\".to_string(),\n \"TIKTOK_CLIENT_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::Vimeo,\n vec![\n \"VIMEO_CLIENT_ID\".to_string(),\n \"VIMEO_CLIENT_SECRET\".to_string(),\n \"VIMEO_ACCESS_TOKEN\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::Telegram,\n vec![\n \"TELEGRAM_BOT_TOKEN\".to_string(),\n \"TELEGRAM_CHAT_ID\".to_string(),\n \"TELEGRAM_CHANNEL_ID\".to_string(),\n ],\n );\n\n // Разработка\n mappings.insert(\n ApiKeyType::Codecov,\n vec![\"CODECOV_TOKEN\".to_string(), \"CODECOV_API_TOKEN\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::TauriAnalytics,\n vec![\n \"TAURI_ANALYTICS_KEY\".to_string(),\n \"TAURI_ANALYTICS_TOKEN\".to_string(),\n ],\n );\n\n mappings\n }\n\n /// Импортирует один API ключ по типу и списку переменных\n fn import_single_api_key(\n &self,\n key_type: ApiKeyType,\n _env_vars: &[String],\n ) -> Result> {\n match key_type {\n // Простые API ключи\n ApiKeyType::OpenAI\n | ApiKeyType::Claude\n | ApiKeyType::MCPClaude\n | ApiKeyType::DeepSeek\n | ApiKeyType::Grok\n | ApiKeyType::Codecov\n | ApiKeyType::TauriAnalytics => {\n for env_var in _env_vars {\n if let Ok(value) = env::var(env_var) {\n if !value.is_empty() {\n return Ok(Some(ApiKeyData {\n key_type,\n value,\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }));\n }\n }\n }\n Ok(None)\n }\n\n // OAuth сервисы\n ApiKeyType::YouTube => self.import_youtube_oauth(_env_vars),\n ApiKeyType::TikTok => self.import_tiktok_oauth(_env_vars),\n ApiKeyType::Vimeo => self.import_vimeo_oauth(_env_vars),\n ApiKeyType::Telegram => self.import_telegram_credentials(_env_vars),\n }\n }\n\n /// Импортирует YouTube OAuth credentials\n fn import_youtube_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"YOUTUBE_CLIENT_ID\", \"GOOGLE_CLIENT_ID\"]);\n\n let client_secret = self.find_env_value(&[\"YOUTUBE_CLIENT_SECRET\", \"GOOGLE_CLIENT_SECRET\"]);\n\n if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::YouTube,\n value: client_id, // Используем client_id как основное значение\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует TikTok OAuth credentials\n fn import_tiktok_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"TIKTOK_CLIENT_ID\", \"TIKTOK_CLIENT_KEY\"]);\n\n let client_secret = self.find_env_value(&[\"TIKTOK_CLIENT_SECRET\"]);\n\n if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::TikTok,\n value: client_id,\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует Vimeo credentials\n fn import_vimeo_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"VIMEO_CLIENT_ID\"]);\n let client_secret = self.find_env_value(&[\"VIMEO_CLIENT_SECRET\"]);\n let access_token = self.find_env_value(&[\"VIMEO_ACCESS_TOKEN\"]);\n\n if let Some(access_token) = access_token {\n // Если есть access token, создаем OAuth credentials\n let oauth_data = if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n Some(OAuthCredentials {\n client_id,\n client_secret,\n access_token: Some(access_token.clone()),\n refresh_token: None,\n expires_at: None,\n })\n } else {\n None\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Vimeo,\n value: access_token,\n oauth_data,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n // Только OAuth credentials без access token\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Vimeo,\n value: client_id,\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует Telegram bot credentials\n fn import_telegram_credentials(&self, _env_vars: &[String]) -> Result> {\n let bot_token = self.find_env_value(&[\"TELEGRAM_BOT_TOKEN\"]);\n\n if let Some(bot_token) = bot_token {\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Telegram,\n value: bot_token,\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Ищет значение в списке переменных окружения\n fn find_env_value(&self, env_names: &[&str]) -> Option {\n for env_name in env_names {\n if let Ok(value) = env::var(env_name) {\n if !value.is_empty() {\n return Some(value);\n }\n }\n }\n None\n }\n\n /// Экспортирует API ключи в .env формат\n pub fn export_to_env_format(&self, api_keys: &[ApiKeyData]) -> String {\n let mut env_content = String::new();\n env_content.push_str(\"# Timeline Studio API Keys\\n\");\n env_content.push_str(\"# Generated by EnvImporter\\n\");\n env_content.push_str(&format!(\n \"# Generated at: {}\\n\\n\",\n chrono::Utc::now().format(\"%Y-%m-%d %H:%M:%S UTC\")\n ));\n\n for key_data in api_keys {\n match key_data.key_type {\n ApiKeyType::OpenAI => {\n env_content.push_str(&format!(\"OPENAI_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Claude => {\n env_content.push_str(&format!(\"CLAUDE_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::MCPClaude => {\n env_content.push_str(&format!(\"MCP_CLAUDE_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::DeepSeek => {\n env_content.push_str(&format!(\"DEEPSEEK_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Grok => {\n env_content.push_str(&format!(\"GROK_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::YouTube => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"YOUTUBE_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"YOUTUBE_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n if let Some(access_token) = &oauth_data.access_token {\n env_content.push_str(&format!(\"YOUTUBE_ACCESS_TOKEN=\\\"{access_token}\\\"\\n\"));\n }\n }\n }\n ApiKeyType::TikTok => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"TIKTOK_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"TIKTOK_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n }\n }\n ApiKeyType::Vimeo => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"VIMEO_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"VIMEO_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n if let Some(access_token) = &oauth_data.access_token {\n env_content.push_str(&format!(\"VIMEO_ACCESS_TOKEN=\\\"{access_token}\\\"\\n\"));\n }\n } else {\n env_content.push_str(&format!(\"VIMEO_ACCESS_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n }\n ApiKeyType::Telegram => {\n env_content.push_str(&format!(\"TELEGRAM_BOT_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Codecov => {\n env_content.push_str(&format!(\"CODECOV_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::TauriAnalytics => {\n env_content.push_str(&format!(\"TAURI_ANALYTICS_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n }\n env_content.push('\\n');\n }\n\n env_content\n }\n\n /// Проверяет наличие .env файла в стандартных местах\n #[allow(dead_code)]\n pub fn find_env_file() -> Option {\n let possible_paths = [\".env\", \".env.local\", \"../.env\", \"../../.env\"];\n\n for path in &possible_paths {\n if Path::new(path).exists() {\n return Some(path.to_string());\n }\n }\n\n None\n }\n}\n\nimpl Default for EnvImporter {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::env;\n use std::fs;\n use tempfile::TempDir;\n\n #[test]\n fn test_env_importer_new() {\n let importer = EnvImporter::new();\n assert!(importer.env_file_path.is_none());\n }\n\n #[test]\n fn test_env_importer_with_env_file() {\n let importer = EnvImporter::with_env_file(\"/path/to/.env\");\n assert_eq!(importer.env_file_path, Some(\"/path/to/.env\".to_string()));\n }\n\n #[test]\n fn test_env_importer_default() {\n let importer = EnvImporter::default();\n assert!(importer.env_file_path.is_none());\n }\n\n #[test]\n fn test_load_env_file_missing() {\n let importer = EnvImporter::with_env_file(\"/non/existent/path/.env\");\n let result = importer.load_env_file();\n assert!(result.is_ok()); // Should not fail on missing file\n }\n\n #[test]\n fn test_load_env_file_valid() {\n let temp_dir = TempDir::new().unwrap();\n let env_file = temp_dir.path().join(\".env\");\n\n let content = r#\"\n# Comment line\nTEST_KEY=test_value\nTEST_KEY_QUOTED=\"quoted value\"\nTEST_KEY_SINGLE='single quoted'\n\n# Another comment\nEMPTY_LINE_ABOVE=value\n \"#;\n\n fs::write(&env_file, content).unwrap();\n\n let importer = EnvImporter::with_env_file(&env_file);\n let result = importer.load_env_file();\n assert!(result.is_ok());\n\n // Check that variables were set\n assert_eq!(env::var(\"TEST_KEY\").unwrap(), \"test_value\");\n assert_eq!(env::var(\"TEST_KEY_QUOTED\").unwrap(), \"quoted value\");\n assert_eq!(env::var(\"TEST_KEY_SINGLE\").unwrap(), \"single quoted\");\n assert_eq!(env::var(\"EMPTY_LINE_ABOVE\").unwrap(), \"value\");\n\n // Clean up\n unsafe {\n env::remove_var(\"TEST_KEY\");\n env::remove_var(\"TEST_KEY_QUOTED\");\n env::remove_var(\"TEST_KEY_SINGLE\");\n env::remove_var(\"EMPTY_LINE_ABOVE\");\n }\n }\n\n #[test]\n fn test_get_env_mappings() {\n let importer = EnvImporter::new();\n let mappings = importer.get_env_mappings();\n\n // Check that all API key types have mappings\n assert!(mappings.contains_key(&ApiKeyType::OpenAI));\n assert!(mappings.contains_key(&ApiKeyType::Claude));\n assert!(mappings.contains_key(&ApiKeyType::DeepSeek));\n assert!(mappings.contains_key(&ApiKeyType::Grok));\n assert!(mappings.contains_key(&ApiKeyType::YouTube));\n assert!(mappings.contains_key(&ApiKeyType::TikTok));\n assert!(mappings.contains_key(&ApiKeyType::Vimeo));\n assert!(mappings.contains_key(&ApiKeyType::Telegram));\n assert!(mappings.contains_key(&ApiKeyType::Codecov));\n assert!(mappings.contains_key(&ApiKeyType::TauriAnalytics));\n\n // Check specific mappings\n let openai_vars = mappings.get(&ApiKeyType::OpenAI).unwrap();\n assert!(openai_vars.contains(&\"OPENAI_API_KEY\".to_string()));\n\n let claude_vars = mappings.get(&ApiKeyType::Claude).unwrap();\n assert!(claude_vars.contains(&\"CLAUDE_API_KEY\".to_string()));\n assert!(claude_vars.contains(&\"ANTHROPIC_API_KEY\".to_string()));\n }\n\n #[test]\n fn test_import_single_api_key_simple() {\n let importer = EnvImporter::new();\n\n // Set test environment variable\n unsafe {\n env::set_var(\"OPENAI_API_KEY\", \"test_openai_key_123\");\n }\n\n let env_vars = vec![\"OPENAI_API_KEY\".to_string()];\n let result = importer\n .import_single_api_key(ApiKeyType::OpenAI, &env_vars)\n .unwrap();\n\n assert!(result.is_some());\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::OpenAI);\n assert_eq!(key_data.value, \"test_openai_key_123\");\n\n // Clean up\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n }\n }\n\n #[test]\n fn test_import_single_api_key_oauth() {\n let importer = EnvImporter::new();\n\n // Set OAuth environment variables\n unsafe {\n env::set_var(\"YOUTUBE_CLIENT_ID\", \"youtube_client_123\");\n env::set_var(\"YOUTUBE_CLIENT_SECRET\", \"youtube_secret_456\");\n }\n\n let env_vars = vec![\n \"YOUTUBE_CLIENT_ID\".to_string(),\n \"YOUTUBE_CLIENT_SECRET\".to_string(),\n ];\n let result = importer\n .import_single_api_key(ApiKeyType::YouTube, &env_vars)\n .unwrap();\n\n assert!(result.is_some());\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::YouTube);\n assert_eq!(key_data.value, \"youtube_client_123\"); // value stores client_id\n\n // OAuth credentials should be in oauth_data field\n assert!(key_data.oauth_data.is_some());\n let oauth_data = key_data.oauth_data.unwrap();\n assert_eq!(oauth_data.client_id, \"youtube_client_123\");\n assert_eq!(oauth_data.client_secret, \"youtube_secret_456\");\n\n // Clean up\n unsafe {\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n }\n }\n\n #[test]\n fn test_import_single_api_key_missing() {\n let importer = EnvImporter::new();\n\n let env_vars = vec![\"NONEXISTENT_KEY\".to_string()];\n let result = importer\n .import_single_api_key(ApiKeyType::OpenAI, &env_vars)\n .unwrap();\n\n assert!(result.is_none());\n }\n\n #[test]\n fn test_import_all_api_keys() {\n // Clear all environment variables first\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_KEY\");\n env::remove_var(\"CLAUDE_API_KEY\");\n env::remove_var(\"ANTHROPIC_API_KEY\");\n env::remove_var(\"CLAUDE_KEY\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_KEY\");\n env::remove_var(\"GROK_API_KEY\");\n env::remove_var(\"GROK_KEY\");\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n env::remove_var(\"TIKTOK_CLIENT_ID\");\n env::remove_var(\"TIKTOK_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_CLIENT_ID\");\n env::remove_var(\"VIMEO_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_ACCESS_TOKEN\");\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"CODECOV_TOKEN\");\n env::remove_var(\"TAURI_ANALYTICS_KEY\");\n }\n\n let importer = EnvImporter::new();\n\n // Set various test environment variables\n unsafe {\n env::set_var(\"OPENAI_API_KEY\", \"openai_test_key\");\n env::set_var(\"CLAUDE_API_KEY\", \"claude_test_key\");\n env::set_var(\"TELEGRAM_BOT_TOKEN\", \"bot_token_123\");\n env::set_var(\"VIMEO_ACCESS_TOKEN\", \"vimeo_token_456\");\n }\n\n let result = importer.import_all_api_keys();\n assert!(result.is_ok());\n\n let keys = result.unwrap();\n assert_eq!(\n keys.len(),\n 4,\n \"Expected exactly 4 keys, found: {}\",\n keys.len()\n ); // Exactly the keys we set\n\n // Check that our keys are present\n let has_openai = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::OpenAI && k.value == \"openai_test_key\");\n let has_claude = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Claude && k.value == \"claude_test_key\");\n let has_telegram = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Telegram && k.value.contains(\"bot_token_123\"));\n let has_vimeo = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Vimeo && k.value == \"vimeo_token_456\");\n\n assert!(has_openai);\n assert!(has_claude);\n assert!(has_telegram);\n assert!(has_vimeo);\n\n // Clean up - remove all test variables\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"CLAUDE_API_KEY\");\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"VIMEO_ACCESS_TOKEN\");\n env::remove_var(\"OPENAI_KEY\");\n env::remove_var(\"ANTHROPIC_API_KEY\");\n env::remove_var(\"CLAUDE_KEY\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_KEY\");\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n env::remove_var(\"TIKTOK_CLIENT_ID\");\n env::remove_var(\"TIKTOK_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_CLIENT_ID\");\n env::remove_var(\"VIMEO_CLIENT_SECRET\");\n env::remove_var(\"CODECOV_TOKEN\");\n env::remove_var(\"TAURI_ANALYTICS_KEY\");\n }\n }\n\n #[test]\n fn test_import_api_key() {\n let importer = EnvImporter::new();\n\n // Test with existing key\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"deepseek_test_123\");\n }\n\n let result = importer.import_api_key(ApiKeyType::DeepSeek);\n assert!(result.is_ok());\n\n let key_data = result.unwrap();\n assert!(key_data.is_some());\n let key = key_data.unwrap();\n assert_eq!(key.key_type, ApiKeyType::DeepSeek);\n assert_eq!(key.value, \"deepseek_test_123\");\n\n // Test with non-existing key\n // First ensure the env var is not set\n unsafe {\n env::remove_var(\"CODECOV_TOKEN\");\n }\n\n let result = importer.import_api_key(ApiKeyType::Codecov);\n assert!(result.is_ok());\n assert!(result.unwrap().is_none());\n\n // Clean up\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n }\n\n #[test]\n fn test_export_to_env_format_simple() {\n let keys = vec![\n ApiKeyData {\n key_type: ApiKeyType::OpenAI,\n value: \"openai_key_123\".to_string(),\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n },\n ApiKeyData {\n key_type: ApiKeyType::Claude,\n value: \"claude_key_456\".to_string(),\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n },\n ];\n\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n // export_to_env_format uses different header\n assert!(env_content.contains(\"# Timeline Studio API Keys\"));\n assert!(env_content.contains(\"OPENAI_API_KEY\"));\n assert!(env_content.contains(\"CLAUDE_API_KEY\"));\n }\n\n #[test]\n fn test_export_to_env_format_oauth() {\n let oauth_creds = OAuthCredentials {\n client_id: \"youtube_client\".to_string(),\n client_secret: \"youtube_secret\".to_string(),\n access_token: Some(\"youtube_token\".to_string()),\n refresh_token: Some(\"youtube_refresh\".to_string()),\n expires_at: None,\n };\n\n let keys = vec![ApiKeyData {\n key_type: ApiKeyType::YouTube,\n value: \"youtube_client\".to_string(),\n oauth_data: Some(oauth_creds),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }];\n\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n assert!(env_content.contains(\"YOUTUBE_CLIENT_ID\"));\n assert!(env_content.contains(\"YOUTUBE_CLIENT_SECRET\"));\n assert!(env_content.contains(\"YOUTUBE_ACCESS_TOKEN\"));\n }\n\n #[test]\n fn test_export_to_env_format_empty() {\n let keys = vec![];\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n assert!(env_content.contains(\"# Timeline Studio API Keys\"));\n assert!(env_content.contains(\"# Generated\"));\n }\n\n #[test]\n fn test_oauth_types_handling() {\n // Test that OAuth types are handled differently in import methods\n // YouTube, TikTok, and Vimeo use OAuth\n let oauth_types = [ApiKeyType::YouTube, ApiKeyType::TikTok, ApiKeyType::Vimeo];\n\n // Simple API key types\n let simple_types = [ApiKeyType::OpenAI, ApiKeyType::Claude, ApiKeyType::DeepSeek];\n\n // Just verify the enums exist and can be used\n assert_eq!(oauth_types.len(), 3);\n assert_eq!(simple_types.len(), 3);\n }\n\n #[test]\n fn test_telegram_special_handling() {\n let importer = EnvImporter::new();\n\n // Test Telegram with both bot token and chat ID\n unsafe {\n env::set_var(\"TELEGRAM_BOT_TOKEN\", \"bot_token_123\");\n env::set_var(\"TELEGRAM_CHAT_ID\", \"chat_id_456\");\n }\n\n let env_vars = vec![\n \"TELEGRAM_BOT_TOKEN\".to_string(),\n \"TELEGRAM_CHAT_ID\".to_string(),\n \"TELEGRAM_CHANNEL_ID\".to_string(),\n ];\n\n let result = importer\n .import_single_api_key(ApiKeyType::Telegram, &env_vars)\n .unwrap();\n assert!(result.is_some());\n\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::Telegram);\n assert_eq!(key_data.value, \"bot_token_123\");\n // Telegram only imports bot token, not chat_id\n\n // Clean up\n unsafe {\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"TELEGRAM_CHAT_ID\");\n }\n }\n\n #[test]\n fn test_load_env_file_malformed() {\n let temp_dir = TempDir::new().unwrap();\n let env_file = temp_dir.path().join(\".env\");\n\n let content = r#\"\nVALID_KEY=valid_value\nINVALID_LINE_NO_EQUALS\nKEY_WITH_EQUALS_IN_VALUE=value=with=equals\nKEY_WITH_SPACES = value with spaces \n \"#;\n\n fs::write(&env_file, content).unwrap();\n\n let importer = EnvImporter::with_env_file(&env_file);\n let result = importer.load_env_file();\n assert!(result.is_ok());\n\n // Check that valid lines were processed\n assert_eq!(env::var(\"VALID_KEY\").unwrap(), \"valid_value\");\n assert_eq!(\n env::var(\"KEY_WITH_EQUALS_IN_VALUE\").unwrap(),\n \"value=with=equals\"\n );\n assert_eq!(env::var(\"KEY_WITH_SPACES\").unwrap(), \"value with spaces\");\n\n // Invalid line should be skipped\n assert!(env::var(\"INVALID_LINE_NO_EQUALS\").is_err());\n\n // Clean up\n unsafe {\n env::remove_var(\"VALID_KEY\");\n env::remove_var(\"KEY_WITH_EQUALS_IN_VALUE\");\n env::remove_var(\"KEY_WITH_SPACES\");\n }\n }\n}\n"}, "763684af87be3d31e891673d37e55b3cf2987430": {"t": 1785756933.903498, "n": 26603, "h": "464bff60936adf12", "c": "use super::{ApiKeyData, ApiKeyType, OAuthCredentials};\nuse anyhow::{Context, Result};\nuse std::collections::HashMap;\nuse std::env;\nuse std::fs;\nuse std::path::Path;\n\n/// Импортер для загрузки API ключей из .env файлов\npub struct EnvImporter {\n env_file_path: Option,\n}\n\nimpl EnvImporter {\n /// Создает новый импортер\n pub fn new() -> Self {\n Self {\n env_file_path: None,\n }\n }\n\n /// Создает импортер с указанным путем к .env файлу\n pub fn with_env_file>(env_file_path: P) -> Self {\n Self {\n env_file_path: Some(env_file_path.as_ref().to_string_lossy().to_string()),\n }\n }\n\n /// Загружает .env файл если указан путь\n pub fn load_env_file(&self) -> Result<()> {\n if let Some(env_path) = &self.env_file_path {\n if Path::new(env_path).exists() {\n let content = fs::read_to_string(env_path).context(\"Failed to read .env file\")?;\n\n for line in content.lines() {\n let line = line.trim();\n if line.is_empty() || line.starts_with('#') {\n continue;\n }\n\n if let Some((key, value)) = line.split_once('=') {\n let key = key.trim();\n let value = value.trim().trim_matches('\"').trim_matches('\\'');\n unsafe { env::set_var(key, value) };\n }\n }\n\n log::info!(\"Loaded .env file: {env_path}\");\n } else {\n log::warn!(\".env file not found: {env_path}\");\n }\n }\n\n Ok(())\n }\n\n /// Импортирует все доступные API ключи из переменных окружения\n pub fn import_all_api_keys(&self) -> Result> {\n // Сначала загружаем .env файл если указан\n self.load_env_file()?;\n\n let mut imported_keys = Vec::new();\n\n // Карта переменных окружения для каждого типа ключа\n let env_mappings = self.get_env_mappings();\n\n for (key_type, env_vars) in env_mappings {\n if let Some(key_data) = self.import_single_api_key(key_type, &env_vars)? {\n imported_keys.push(key_data);\n }\n }\n\n log::info!(\"Imported {} API keys from environment\", imported_keys.len());\n Ok(imported_keys)\n }\n\n /// Импортирует конкретный API ключ\n #[allow(dead_code)]\n pub fn import_api_key(&self, key_type: ApiKeyType) -> Result> {\n self.load_env_file()?;\n\n let env_mappings = self.get_env_mappings();\n if let Some(env_vars) = env_mappings.get(&key_type) {\n self.import_single_api_key(key_type, env_vars)\n } else {\n Ok(None)\n }\n }\n\n /// Получает карту переменных окружения для каждого типа ключа\n fn get_env_mappings(&self) -> HashMap> {\n let mut mappings = HashMap::new();\n\n // AI сервисы\n mappings.insert(\n ApiKeyType::OpenAI,\n vec![\"OPENAI_API_KEY\".to_string(), \"OPENAI_KEY\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::Claude,\n vec![\n \"CLAUDE_API_KEY\".to_string(),\n \"ANTHROPIC_API_KEY\".to_string(),\n \"CLAUDE_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::MCPClaude,\n vec![\n \"MCP_CLAUDE_API_KEY\".to_string(),\n \"MCP_ANTHROPIC_API_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::DeepSeek,\n vec![\"DEEPSEEK_API_KEY\".to_string(), \"DEEPSEEK_KEY\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::Grok,\n vec![\"GROK_API_KEY\".to_string(), \"GROK_KEY\".to_string()],\n );\n\n // Социальные сети - OAuth credentials\n mappings.insert(\n ApiKeyType::YouTube,\n vec![\n \"YOUTUBE_CLIENT_ID\".to_string(),\n \"YOUTUBE_CLIENT_SECRET\".to_string(),\n \"GOOGLE_CLIENT_ID\".to_string(),\n \"GOOGLE_CLIENT_SECRET\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::TikTok,\n vec![\n \"TIKTOK_CLIENT_ID\".to_string(),\n \"TIKTOK_CLIENT_SECRET\".to_string(),\n \"TIKTOK_CLIENT_KEY\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::Vimeo,\n vec![\n \"VIMEO_CLIENT_ID\".to_string(),\n \"VIMEO_CLIENT_SECRET\".to_string(),\n \"VIMEO_ACCESS_TOKEN\".to_string(),\n ],\n );\n\n mappings.insert(\n ApiKeyType::Telegram,\n vec![\n \"TELEGRAM_BOT_TOKEN\".to_string(),\n \"TELEGRAM_CHAT_ID\".to_string(),\n \"TELEGRAM_CHANNEL_ID\".to_string(),\n ],\n );\n\n // Разработка\n mappings.insert(\n ApiKeyType::Codecov,\n vec![\"CODECOV_TOKEN\".to_string(), \"CODECOV_API_TOKEN\".to_string()],\n );\n\n mappings.insert(\n ApiKeyType::TauriAnalytics,\n vec![\n \"TAURI_ANALYTICS_KEY\".to_string(),\n \"TAURI_ANALYTICS_TOKEN\".to_string(),\n ],\n );\n\n mappings\n }\n\n /// Импортирует один API ключ по типу и списку переменных\n fn import_single_api_key(\n &self,\n key_type: ApiKeyType,\n _env_vars: &[String],\n ) -> Result> {\n match key_type {\n // Простые API ключи\n ApiKeyType::OpenAI\n | ApiKeyType::Claude\n | ApiKeyType::MCPClaude\n | ApiKeyType::DeepSeek\n | ApiKeyType::Grok\n | ApiKeyType::Codecov\n | ApiKeyType::TauriAnalytics => {\n for env_var in _env_vars {\n if let Ok(value) = env::var(env_var) {\n if !value.is_empty() {\n return Ok(Some(ApiKeyData {\n key_type,\n value,\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }));\n }\n }\n }\n Ok(None)\n }\n\n // OAuth сервисы\n ApiKeyType::YouTube => self.import_youtube_oauth(_env_vars),\n ApiKeyType::TikTok => self.import_tiktok_oauth(_env_vars),\n ApiKeyType::Vimeo => self.import_vimeo_oauth(_env_vars),\n ApiKeyType::Telegram => self.import_telegram_credentials(_env_vars),\n }\n }\n\n /// Импортирует YouTube OAuth credentials\n fn import_youtube_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"YOUTUBE_CLIENT_ID\", \"GOOGLE_CLIENT_ID\"]);\n\n let client_secret = self.find_env_value(&[\"YOUTUBE_CLIENT_SECRET\", \"GOOGLE_CLIENT_SECRET\"]);\n\n if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::YouTube,\n value: client_id, // Используем client_id как основное значение\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует TikTok OAuth credentials\n fn import_tiktok_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"TIKTOK_CLIENT_ID\", \"TIKTOK_CLIENT_KEY\"]);\n\n let client_secret = self.find_env_value(&[\"TIKTOK_CLIENT_SECRET\"]);\n\n if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::TikTok,\n value: client_id,\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует Vimeo credentials\n fn import_vimeo_oauth(&self, _env_vars: &[String]) -> Result> {\n let client_id = self.find_env_value(&[\"VIMEO_CLIENT_ID\"]);\n let client_secret = self.find_env_value(&[\"VIMEO_CLIENT_SECRET\"]);\n let access_token = self.find_env_value(&[\"VIMEO_ACCESS_TOKEN\"]);\n\n if let Some(access_token) = access_token {\n // Если есть access token, создаем OAuth credentials\n let oauth_data = if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n Some(OAuthCredentials {\n client_id,\n client_secret,\n access_token: Some(access_token.clone()),\n refresh_token: None,\n expires_at: None,\n })\n } else {\n None\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Vimeo,\n value: access_token,\n oauth_data,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else if let (Some(client_id), Some(client_secret)) = (client_id, client_secret) {\n // Только OAuth credentials без access token\n let oauth_data = OAuthCredentials {\n client_id: client_id.clone(),\n client_secret,\n access_token: None,\n refresh_token: None,\n expires_at: None,\n };\n\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Vimeo,\n value: client_id,\n oauth_data: Some(oauth_data),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Импортирует Telegram bot credentials\n fn import_telegram_credentials(&self, _env_vars: &[String]) -> Result> {\n let bot_token = self.find_env_value(&[\"TELEGRAM_BOT_TOKEN\"]);\n\n if let Some(bot_token) = bot_token {\n Ok(Some(ApiKeyData {\n key_type: ApiKeyType::Telegram,\n value: bot_token,\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }))\n } else {\n Ok(None)\n }\n }\n\n /// Ищет значение в списке переменных окружения\n fn find_env_value(&self, env_names: &[&str]) -> Option {\n for env_name in env_names {\n if let Ok(value) = env::var(env_name) {\n if !value.is_empty() {\n return Some(value);\n }\n }\n }\n None\n }\n\n /// Экспортирует API ключи в .env формат\n pub fn export_to_env_format(&self, api_keys: &[ApiKeyData]) -> String {\n let mut env_content = String::new();\n env_content.push_str(\"# Timeline Studio API Keys\\n\");\n env_content.push_str(\"# Generated by EnvImporter\\n\");\n env_content.push_str(&format!(\n \"# Generated at: {}\\n\\n\",\n chrono::Utc::now().format(\"%Y-%m-%d %H:%M:%S UTC\")\n ));\n\n for key_data in api_keys {\n match key_data.key_type {\n ApiKeyType::OpenAI => {\n env_content.push_str(&format!(\"OPENAI_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Claude => {\n env_content.push_str(&format!(\"CLAUDE_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::MCPClaude => {\n env_content.push_str(&format!(\"MCP_CLAUDE_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::DeepSeek => {\n env_content.push_str(&format!(\"DEEPSEEK_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Grok => {\n env_content.push_str(&format!(\"GROK_API_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::YouTube => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"YOUTUBE_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"YOUTUBE_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n if let Some(access_token) = &oauth_data.access_token {\n env_content.push_str(&format!(\"YOUTUBE_ACCESS_TOKEN=\\\"{access_token}\\\"\\n\"));\n }\n }\n }\n ApiKeyType::TikTok => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"TIKTOK_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"TIKTOK_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n }\n }\n ApiKeyType::Vimeo => {\n if let Some(oauth_data) = &key_data.oauth_data {\n env_content.push_str(&format!(\"VIMEO_CLIENT_ID=\\\"{}\\\"\\n\", oauth_data.client_id));\n env_content.push_str(&format!(\n \"VIMEO_CLIENT_SECRET=\\\"{}\\\"\\n\",\n oauth_data.client_secret\n ));\n if let Some(access_token) = &oauth_data.access_token {\n env_content.push_str(&format!(\"VIMEO_ACCESS_TOKEN=\\\"{access_token}\\\"\\n\"));\n }\n } else {\n env_content.push_str(&format!(\"VIMEO_ACCESS_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n }\n ApiKeyType::Telegram => {\n env_content.push_str(&format!(\"TELEGRAM_BOT_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::Codecov => {\n env_content.push_str(&format!(\"CODECOV_TOKEN=\\\"{}\\\"\\n\", key_data.value));\n }\n ApiKeyType::TauriAnalytics => {\n env_content.push_str(&format!(\"TAURI_ANALYTICS_KEY=\\\"{}\\\"\\n\", key_data.value));\n }\n }\n env_content.push('\\n');\n }\n\n env_content\n }\n\n /// Проверяет наличие .env файла в стандартных местах\n #[allow(dead_code)]\n pub fn find_env_file() -> Option {\n let possible_paths = [\".env\", \".env.local\", \"../.env\", \"../../.env\"];\n\n for path in &possible_paths {\n if Path::new(path).exists() {\n return Some(path.to_string());\n }\n }\n\n None\n }\n}\n\nimpl Default for EnvImporter {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::env;\n use std::fs;\n use tempfile::TempDir;\n\n #[test]\n fn test_env_importer_new() {\n let importer = EnvImporter::new();\n assert!(importer.env_file_path.is_none());\n }\n\n #[test]\n fn test_env_importer_with_env_file() {\n let importer = EnvImporter::with_env_file(\"/path/to/.env\");\n assert_eq!(importer.env_file_path, Some(\"/path/to/.env\".to_string()));\n }\n\n #[test]\n fn test_env_importer_default() {\n let importer = EnvImporter::default();\n assert!(importer.env_file_path.is_none());\n }\n\n #[test]\n fn test_load_env_file_missing() {\n let importer = EnvImporter::with_env_file(\"/non/existent/path/.env\");\n let result = importer.load_env_file();\n assert!(result.is_ok()); // Should not fail on missing file\n }\n\n #[test]\n fn test_load_env_file_valid() {\n let temp_dir = TempDir::new().unwrap();\n let env_file = temp_dir.path().join(\".env\");\n\n let content = r#\"\n# Comment line\nTEST_KEY=test_value\nTEST_KEY_QUOTED=\"quoted value\"\nTEST_KEY_SINGLE='single quoted'\n\n# Another comment\nEMPTY_LINE_ABOVE=value\n \"#;\n\n fs::write(&env_file, content).unwrap();\n\n let importer = EnvImporter::with_env_file(&env_file);\n let result = importer.load_env_file();\n assert!(result.is_ok());\n\n // Check that variables were set\n assert_eq!(env::var(\"TEST_KEY\").unwrap(), \"test_value\");\n assert_eq!(env::var(\"TEST_KEY_QUOTED\").unwrap(), \"quoted value\");\n assert_eq!(env::var(\"TEST_KEY_SINGLE\").unwrap(), \"single quoted\");\n assert_eq!(env::var(\"EMPTY_LINE_ABOVE\").unwrap(), \"value\");\n\n // Clean up\n unsafe {\n env::remove_var(\"TEST_KEY\");\n env::remove_var(\"TEST_KEY_QUOTED\");\n env::remove_var(\"TEST_KEY_SINGLE\");\n env::remove_var(\"EMPTY_LINE_ABOVE\");\n }\n }\n\n #[test]\n fn test_get_env_mappings() {\n let importer = EnvImporter::new();\n let mappings = importer.get_env_mappings();\n\n // Check that all API key types have mappings\n assert!(mappings.contains_key(&ApiKeyType::OpenAI));\n assert!(mappings.contains_key(&ApiKeyType::Claude));\n assert!(mappings.contains_key(&ApiKeyType::DeepSeek));\n assert!(mappings.contains_key(&ApiKeyType::Grok));\n assert!(mappings.contains_key(&ApiKeyType::YouTube));\n assert!(mappings.contains_key(&ApiKeyType::TikTok));\n assert!(mappings.contains_key(&ApiKeyType::Vimeo));\n assert!(mappings.contains_key(&ApiKeyType::Telegram));\n assert!(mappings.contains_key(&ApiKeyType::Codecov));\n assert!(mappings.contains_key(&ApiKeyType::TauriAnalytics));\n\n // Check specific mappings\n let openai_vars = mappings.get(&ApiKeyType::OpenAI).unwrap();\n assert!(openai_vars.contains(&\"OPENAI_API_KEY\".to_string()));\n\n let claude_vars = mappings.get(&ApiKeyType::Claude).unwrap();\n assert!(claude_vars.contains(&\"CLAUDE_API_KEY\".to_string()));\n assert!(claude_vars.contains(&\"ANTHROPIC_API_KEY\".to_string()));\n }\n\n #[test]\n fn test_import_single_api_key_simple() {\n let importer = EnvImporter::new();\n\n // Set test environment variable\n unsafe {\n env::set_var(\"OPENAI_API_KEY\", \"test_openai_key_123\");\n }\n\n let env_vars = vec![\"OPENAI_API_KEY\".to_string()];\n let result = importer\n .import_single_api_key(ApiKeyType::OpenAI, &env_vars)\n .unwrap();\n\n assert!(result.is_some());\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::OpenAI);\n assert_eq!(key_data.value, \"test_openai_key_123\");\n\n // Clean up\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n }\n }\n\n #[test]\n fn test_import_single_api_key_oauth() {\n let importer = EnvImporter::new();\n\n // Set OAuth environment variables\n unsafe {\n env::set_var(\"YOUTUBE_CLIENT_ID\", \"youtube_client_123\");\n env::set_var(\"YOUTUBE_CLIENT_SECRET\", \"youtube_secret_456\");\n }\n\n let env_vars = vec![\n \"YOUTUBE_CLIENT_ID\".to_string(),\n \"YOUTUBE_CLIENT_SECRET\".to_string(),\n ];\n let result = importer\n .import_single_api_key(ApiKeyType::YouTube, &env_vars)\n .unwrap();\n\n assert!(result.is_some());\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::YouTube);\n assert_eq!(key_data.value, \"youtube_client_123\"); // value stores client_id\n\n // OAuth credentials should be in oauth_data field\n assert!(key_data.oauth_data.is_some());\n let oauth_data = key_data.oauth_data.unwrap();\n assert_eq!(oauth_data.client_id, \"youtube_client_123\");\n assert_eq!(oauth_data.client_secret, \"youtube_secret_456\");\n\n // Clean up\n unsafe {\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n }\n }\n\n #[test]\n fn test_import_single_api_key_missing() {\n let importer = EnvImporter::new();\n\n let env_vars = vec![\"NONEXISTENT_KEY\".to_string()];\n let result = importer\n .import_single_api_key(ApiKeyType::OpenAI, &env_vars)\n .unwrap();\n\n assert!(result.is_none());\n }\n\n #[test]\n fn test_import_all_api_keys() {\n // Clear all environment variables first\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"OPENAI_KEY\");\n env::remove_var(\"CLAUDE_API_KEY\");\n env::remove_var(\"ANTHROPIC_API_KEY\");\n env::remove_var(\"CLAUDE_KEY\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_KEY\");\n env::remove_var(\"GROK_API_KEY\");\n env::remove_var(\"GROK_KEY\");\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n env::remove_var(\"TIKTOK_CLIENT_ID\");\n env::remove_var(\"TIKTOK_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_CLIENT_ID\");\n env::remove_var(\"VIMEO_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_ACCESS_TOKEN\");\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"CODECOV_TOKEN\");\n env::remove_var(\"TAURI_ANALYTICS_KEY\");\n }\n\n let importer = EnvImporter::new();\n\n // Set various test environment variables\n unsafe {\n env::set_var(\"OPENAI_API_KEY\", \"openai_test_key\");\n env::set_var(\"CLAUDE_API_KEY\", \"claude_test_key\");\n env::set_var(\"TELEGRAM_BOT_TOKEN\", \"bot_token_123\");\n env::set_var(\"VIMEO_ACCESS_TOKEN\", \"vimeo_token_456\");\n }\n\n let result = importer.import_all_api_keys();\n assert!(result.is_ok());\n\n let keys = result.unwrap();\n assert_eq!(\n keys.len(),\n 4,\n \"Expected exactly 4 keys, found: {}\",\n keys.len()\n ); // Exactly the keys we set\n\n // Check that our keys are present\n let has_openai = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::OpenAI && k.value == \"openai_test_key\");\n let has_claude = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Claude && k.value == \"claude_test_key\");\n let has_telegram = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Telegram && k.value.contains(\"bot_token_123\"));\n let has_vimeo = keys\n .iter()\n .any(|k| k.key_type == ApiKeyType::Vimeo && k.value == \"vimeo_token_456\");\n\n assert!(has_openai);\n assert!(has_claude);\n assert!(has_telegram);\n assert!(has_vimeo);\n\n // Clean up - remove all test variables\n unsafe {\n env::remove_var(\"OPENAI_API_KEY\");\n env::remove_var(\"CLAUDE_API_KEY\");\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"VIMEO_ACCESS_TOKEN\");\n env::remove_var(\"OPENAI_KEY\");\n env::remove_var(\"ANTHROPIC_API_KEY\");\n env::remove_var(\"CLAUDE_KEY\");\n env::remove_var(\"DEEPSEEK_API_KEY\");\n env::remove_var(\"DEEPSEEK_KEY\");\n env::remove_var(\"YOUTUBE_CLIENT_ID\");\n env::remove_var(\"YOUTUBE_CLIENT_SECRET\");\n env::remove_var(\"TIKTOK_CLIENT_ID\");\n env::remove_var(\"TIKTOK_CLIENT_SECRET\");\n env::remove_var(\"VIMEO_CLIENT_ID\");\n env::remove_var(\"VIMEO_CLIENT_SECRET\");\n env::remove_var(\"CODECOV_TOKEN\");\n env::remove_var(\"TAURI_ANALYTICS_KEY\");\n }\n }\n\n #[test]\n fn test_import_api_key() {\n let importer = EnvImporter::new();\n\n // Test with existing key\n unsafe {\n env::set_var(\"DEEPSEEK_API_KEY\", \"deepseek_test_123\");\n }\n\n let result = importer.import_api_key(ApiKeyType::DeepSeek);\n assert!(result.is_ok());\n\n let key_data = result.unwrap();\n assert!(key_data.is_some());\n let key = key_data.unwrap();\n assert_eq!(key.key_type, ApiKeyType::DeepSeek);\n assert_eq!(key.value, \"deepseek_test_123\");\n\n // Test with non-existing key\n // First ensure the env var is not set\n unsafe {\n env::remove_var(\"CODECOV_TOKEN\");\n }\n\n let result = importer.import_api_key(ApiKeyType::Codecov);\n assert!(result.is_ok());\n assert!(result.unwrap().is_none());\n\n // Clean up\n unsafe {\n env::remove_var(\"DEEPSEEK_API_KEY\");\n }\n }\n\n #[test]\n fn test_export_to_env_format_simple() {\n let keys = vec![\n ApiKeyData {\n key_type: ApiKeyType::OpenAI,\n value: \"openai_key_123\".to_string(),\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n },\n ApiKeyData {\n key_type: ApiKeyType::Claude,\n value: \"claude_key_456\".to_string(),\n oauth_data: None,\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n },\n ];\n\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n // export_to_env_format uses different header\n assert!(env_content.contains(\"# Timeline Studio API Keys\"));\n assert!(env_content.contains(\"OPENAI_API_KEY\"));\n assert!(env_content.contains(\"CLAUDE_API_KEY\"));\n }\n\n #[test]\n fn test_export_to_env_format_oauth() {\n let oauth_creds = OAuthCredentials {\n client_id: \"youtube_client\".to_string(),\n client_secret: \"youtube_secret\".to_string(),\n access_token: Some(\"youtube_token\".to_string()),\n refresh_token: Some(\"youtube_refresh\".to_string()),\n expires_at: None,\n };\n\n let keys = vec![ApiKeyData {\n key_type: ApiKeyType::YouTube,\n value: \"youtube_client\".to_string(),\n oauth_data: Some(oauth_creds),\n created_at: chrono::Utc::now(),\n last_validated: None,\n is_valid: None,\n }];\n\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n assert!(env_content.contains(\"YOUTUBE_CLIENT_ID\"));\n assert!(env_content.contains(\"YOUTUBE_CLIENT_SECRET\"));\n assert!(env_content.contains(\"YOUTUBE_ACCESS_TOKEN\"));\n }\n\n #[test]\n fn test_export_to_env_format_empty() {\n let keys = vec![];\n let importer = EnvImporter::new();\n let env_content = importer.export_to_env_format(&keys);\n assert!(env_content.contains(\"# Timeline Studio API Keys\"));\n assert!(env_content.contains(\"# Generated\"));\n }\n\n #[test]\n fn test_oauth_types_handling() {\n // Test that OAuth types are handled differently in import methods\n // YouTube, TikTok, and Vimeo use OAuth\n let oauth_types = [ApiKeyType::YouTube, ApiKeyType::TikTok, ApiKeyType::Vimeo];\n\n // Simple API key types\n let simple_types = [ApiKeyType::OpenAI, ApiKeyType::Claude, ApiKeyType::DeepSeek];\n\n // Just verify the enums exist and can be used\n assert_eq!(oauth_types.len(), 3);\n assert_eq!(simple_types.len(), 3);\n }\n\n #[test]\n fn test_telegram_special_handling() {\n let importer = EnvImporter::new();\n\n // Test Telegram with both bot token and chat ID\n unsafe {\n env::set_var(\"TELEGRAM_BOT_TOKEN\", \"bot_token_123\");\n env::set_var(\"TELEGRAM_CHAT_ID\", \"chat_id_456\");\n }\n\n let env_vars = vec![\n \"TELEGRAM_BOT_TOKEN\".to_string(),\n \"TELEGRAM_CHAT_ID\".to_string(),\n \"TELEGRAM_CHANNEL_ID\".to_string(),\n ];\n\n let result = importer\n .import_single_api_key(ApiKeyType::Telegram, &env_vars)\n .unwrap();\n assert!(result.is_some());\n\n let key_data = result.unwrap();\n assert_eq!(key_data.key_type, ApiKeyType::Telegram);\n assert_eq!(key_data.value, \"bot_token_123\");\n // Telegram only imports bot token, not chat_id\n\n // Clean up\n unsafe {\n env::remove_var(\"TELEGRAM_BOT_TOKEN\");\n env::remove_var(\"TELEGRAM_CHAT_ID\");\n }\n }\n\n #[test]\n fn test_load_env_file_malformed() {\n let temp_dir = TempDir::new().unwrap();\n let env_file = temp_dir.path().join(\".env\");\n\n let content = r#\"\nVALID_KEY=valid_value\nINVALID_LINE_NO_EQUALS\nKEY_WITH_EQUALS_IN_VALUE=value=with=equals\nKEY_WITH_SPACES = value with spaces \n \"#;\n\n fs::write(&env_file, content).unwrap();\n\n let importer = EnvImporter::with_env_file(&env_file);\n let result = importer.load_env_file();\n assert!(result.is_ok());\n\n // Check that valid lines were processed\n assert_eq!(env::var(\"VALID_KEY\").unwrap(), \"valid_value\");\n assert_eq!(\n env::var(\"KEY_WITH_EQUALS_IN_VALUE\").unwrap(),\n \"value=with=equals\"\n );\n assert_eq!(env::var(\"KEY_WITH_SPACES\").unwrap(), \"value with spaces\");\n\n // Invalid line should be skipped\n assert!(env::var(\"INVALID_LINE_NO_EQUALS\").is_err());\n\n // Clean up\n unsafe {\n env::remove_var(\"VALID_KEY\");\n env::remove_var(\"KEY_WITH_EQUALS_IN_VALUE\");\n env::remove_var(\"KEY_WITH_SPACES\");\n }\n }\n}\n"}}, "crates/ts-security/src/api_validator.rs": {"9253fded22485cb087ef100842c6be6a8989dfc4": {"t": 1785746226.4592001, "n": 33887, "h": "5b0b2606e600c710", "c": "use super::{ApiKeyType, OAuthCredentials};\nuse anyhow::Result;\nuse reqwest::Client;\nuse serde_json::{json, Value};\nuse std::time::Duration;\n\n/// Результат валидации API ключа\n#[derive(Debug, Clone, serde::Serialize)]\npub struct ValidationResult {\n pub is_valid: bool,\n pub error_message: Option,\n pub service_info: Option, // Дополнительная информация о сервисе\n pub rate_limits: Option,\n}\n\n/// Информация о лимитах API\n#[derive(Debug, Clone, serde::Serialize)]\npub struct RateLimitInfo {\n pub requests_remaining: Option,\n pub reset_time: Option>,\n pub daily_limit: Option,\n}\n\n/// Валидатор API ключей\npub struct ApiValidator {\n client: Client,\n}\n\nimpl ApiValidator {\n pub fn new() -> Self {\n let client = Client::builder()\n .timeout(Duration::from_secs(10))\n .build()\n .expect(\"Failed to create HTTP client\");\n\n Self { client }\n }\n\n /// Валидирует API ключ в зависимости от типа сервиса\n pub async fn validate_api_key(\n &self,\n key_type: ApiKeyType,\n value: &str,\n ) -> Result {\n match key_type {\n ApiKeyType::OpenAI => self.validate_openai_key(value).await,\n ApiKeyType::Claude => self.validate_claude_key(value).await,\n ApiKeyType::MCPClaude => self.validate_claude_key(value).await, // MCP использует тот же Claude API\n ApiKeyType::DeepSeek => self.validate_deepseek_key(value).await,\n ApiKeyType::Grok => self.validate_grok_key(value).await,\n ApiKeyType::YouTube => self.validate_youtube_oauth(value).await,\n ApiKeyType::TikTok => self.validate_tiktok_oauth(value).await,\n ApiKeyType::Vimeo => self.validate_vimeo_key(value).await,\n ApiKeyType::Telegram => self.validate_telegram_bot(value).await,\n ApiKeyType::Codecov => self.validate_codecov_token(value).await,\n ApiKeyType::TauriAnalytics => self.validate_tauri_analytics(value).await,\n }\n }\n\n /// Валидирует OAuth credentials\n pub async fn validate_oauth_credentials(\n &self,\n key_type: ApiKeyType,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n match key_type {\n ApiKeyType::YouTube => self.validate_youtube_oauth_full(oauth_data).await,\n ApiKeyType::TikTok => self.validate_tiktok_oauth_full(oauth_data).await,\n ApiKeyType::Vimeo => self.validate_vimeo_oauth_full(oauth_data).await,\n _ => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"OAuth validation not supported for this service\".to_string()),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // OpenAI API валидация\n async fn validate_openai_key(&self, api_key: &str) -> Result {\n let response = self\n .client\n .get(\"https://api.openai.com/v1/models\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n let status = resp.status();\n let headers = resp.headers().clone();\n\n if status.is_success() {\n let rate_limits = self.extract_openai_rate_limits(&headers);\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"OpenAI API access confirmed\".to_string()),\n rate_limits,\n })\n } else if status.as_u16() == 401 {\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message = if error_text.contains(\"insufficient_quota\")\n || error_text.contains(\"quota_exceeded\")\n || error_text.contains(\"billing_hard_limit\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://platform.openai.com/account/billing\"\n .to_string()\n } else {\n \"Неверный API ключ\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else if status.as_u16() == 429 {\n let error_text = resp.text().await.unwrap_or_default();\n let error_message = if error_text.contains(\"quota\") || error_text.contains(\"billing\") {\n \"Недостаточно кредитов. Пополните баланс на https://platform.openai.com/account/billing\"\n .to_string()\n } else {\n \"Превышен лимит запросов\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else {\n let error_text = resp.text().await.unwrap_or_default();\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"OpenAI API error {status}: {error_text}\")),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Claude API валидация\n async fn validate_claude_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к Claude API\n let body = json!({\n \"model\": \"claude-3-haiku-20240307\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.anthropic.com/v1/messages\")\n .header(\"x-api-key\", api_key)\n .header(\"anthropic-version\", \"2023-06-01\")\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n if status.is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Claude API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else if status.as_u16() == 401 {\n // Проверяем специфические ошибки для ключа\n let error_message = if error_text.contains(\"credit balance is too low\")\n || error_text.contains(\"Insufficient credits\")\n || error_text.contains(\"quota exceeded\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://console.anthropic.com/billing\"\n .to_string()\n } else {\n \"Неверный API ключ\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else if status.as_u16() == 400 {\n // Проверяем ошибки 400, включая недостаточный баланс\n let error_message = if error_text.contains(\"credit balance is too low\")\n || error_text.contains(\"Insufficient credits\")\n || error_text.contains(\"quota exceeded\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://console.anthropic.com/billing\"\n .to_string()\n } else {\n format!(\"Claude API error {}: {}\", status, error_text)\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Claude API error {}: {}\", status, error_text)),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_deepseek_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к DeepSeek API\n let body = json!({\n \"model\": \"deepseek-chat\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.deepseek.com/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"DeepSeek API\".to_string()),\n rate_limits: None,\n })\n } else {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message = if error_text.contains(\"invalid api key\") {\n \"Неверный API ключ\".to_string()\n } else if error_text.contains(\"insufficient_balance\")\n || error_text.contains(\"insufficient_quota\")\n || error_text.contains(\"quota_exceeded\")\n || error_text.contains(\"Insufficient Balance\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://platform.deepseek.com/usage\"\n .to_string()\n } else {\n format!(\"DeepSeek API error {status}: {error_text}\")\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: Some(\"DeepSeek API\".to_string()),\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_grok_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к Grok API\n let body = json!({\n \"model\": \"grok-beta\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.x.ai/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Grok API\".to_string()),\n rate_limits: None,\n })\n } else {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message =\n if error_text.contains(\"does not have permission\") && error_text.contains(\"credits\") {\n \"Недостаточно кредитов. Пожалуйста, пополните баланс на https://console.x.ai\"\n .to_string()\n } else if error_text.contains(\"Incorrect API key\") {\n \"Неверный API ключ\".to_string()\n } else {\n format!(\"Grok API error {status}: {error_text}\")\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: Some(\"Grok API\".to_string()),\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // YouTube OAuth валидация (проверка client_id:client_secret)\n async fn validate_youtube_oauth(&self, _credentials: &str) -> Result {\n // Для YouTube нужна полная OAuth валидация\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Use OAuth credentials validation for YouTube\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n // YouTube OAuth full валидация\n async fn validate_youtube_oauth_full(\n &self,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n if let Some(access_token) = &oauth_data.access_token {\n // Проверяем access token через YouTube API\n let response = self\n .client\n .get(\"https://www.googleapis.com/youtube/v3/channels\")\n .query(&[(\"part\", \"snippet\"), (\"mine\", \"true\")])\n .header(\"Authorization\", format!(\"Bearer {access_token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"YouTube API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid or expired access token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n } else {\n // Проверяем client credentials через OAuth2 endpoint\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"No access token available, OAuth flow required\".to_string()),\n service_info: Some(\"Client credentials present\".to_string()),\n rate_limits: None,\n })\n }\n }\n\n // TikTok OAuth валидация\n async fn validate_tiktok_oauth(&self, _credentials: &str) -> Result {\n // TikTok API валидация - пока заглушка\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"TikTok API validation not implemented yet\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n async fn validate_tiktok_oauth_full(\n &self,\n _oauth_data: &OAuthCredentials,\n ) -> Result {\n // TikTok OAuth валидация - пока заглушка\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"TikTok OAuth validation not implemented yet\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n // Vimeo API валидация\n async fn validate_vimeo_key(&self, access_token: &str) -> Result {\n let response = self\n .client\n .get(\"https://api.vimeo.com/me\")\n .header(\"Authorization\", format!(\"Bearer {access_token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Vimeo API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid access token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_vimeo_oauth_full(\n &self,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n if let Some(access_token) = &oauth_data.access_token {\n self.validate_vimeo_key(access_token).await\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"No access token available\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n\n // Telegram Bot валидация\n async fn validate_telegram_bot(&self, bot_token: &str) -> Result {\n let url = format!(\"https://api.telegram.org/bot{bot_token}/getMe\");\n let response = self.client.get(&url).send().await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n let json: Value = resp.json().await.unwrap_or_default();\n if json[\"ok\"].as_bool().unwrap_or(false) {\n let bot_name = json[\"result\"][\"username\"].as_str().unwrap_or(\"Unknown\");\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(format!(\"Telegram bot @{bot_name} confirmed\")),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid bot token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid bot token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Codecov token валидация\n async fn validate_codecov_token(&self, token: &str) -> Result {\n let response = self\n .client\n .get(\"https://codecov.io/api/v2/user\")\n .header(\"Authorization\", format!(\"token {token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Codecov token confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid Codecov token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Tauri Analytics валидация (заглушка)\n async fn validate_tauri_analytics(&self, _key: &str) -> Result {\n // Tauri Analytics пока не имеет публичного API для валидации\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Tauri Analytics key format appears valid\".to_string()),\n rate_limits: None,\n })\n }\n\n // Извлечение rate limit информации из OpenAI headers\n fn extract_openai_rate_limits(\n &self,\n headers: &reqwest::header::HeaderMap,\n ) -> Option {\n let requests_remaining = headers\n .get(\"x-ratelimit-remaining-requests\")\n .and_then(|v| v.to_str().ok())\n .and_then(|s| s.parse().ok());\n\n let reset_time = headers\n .get(\"x-ratelimit-reset-requests\")\n .and_then(|v| v.to_str().ok())\n .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())\n .map(|dt| dt.with_timezone(&chrono::Utc));\n\n if requests_remaining.is_some() || reset_time.is_some() {\n Some(RateLimitInfo {\n requests_remaining,\n reset_time,\n daily_limit: None,\n })\n } else {\n None\n }\n }\n}\n\nimpl Default for ApiValidator {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn create_test_validator() -> ApiValidator {\n ApiValidator::new()\n }\n\n #[test]\n fn test_validation_result_creation() {\n let result = ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Test service\".to_string()),\n rate_limits: None,\n };\n\n assert!(result.is_valid);\n assert!(result.error_message.is_none());\n assert_eq!(result.service_info, Some(\"Test service\".to_string()));\n assert!(result.rate_limits.is_none());\n }\n\n #[test]\n fn test_rate_limit_info() {\n let rate_limit = RateLimitInfo {\n requests_remaining: Some(100),\n reset_time: Some(chrono::Utc::now()),\n daily_limit: Some(1000),\n };\n\n assert_eq!(rate_limit.requests_remaining, Some(100));\n assert!(rate_limit.reset_time.is_some());\n assert_eq!(rate_limit.daily_limit, Some(1000));\n }\n\n #[test]\n fn test_validation_result_with_error() {\n let result = ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid API key\".to_string()),\n service_info: None,\n rate_limits: None,\n };\n\n assert!(!result.is_valid);\n assert_eq!(result.error_message, Some(\"Invalid API key\".to_string()));\n assert!(result.service_info.is_none());\n assert!(result.rate_limits.is_none());\n }\n\n #[test]\n fn test_rate_limit_info_partial() {\n let rate_limit = RateLimitInfo {\n requests_remaining: Some(50),\n reset_time: None,\n daily_limit: None,\n };\n\n assert_eq!(rate_limit.requests_remaining, Some(50));\n assert!(rate_limit.reset_time.is_none());\n assert!(rate_limit.daily_limit.is_none());\n }\n\n #[tokio::test]\n async fn test_validate_api_key_unsupported_oauth() {\n let validator = create_test_validator();\n let oauth_creds = OAuthCredentials {\n client_id: \"client_123\".to_string(),\n client_secret: \"secret_456\".to_string(),\n access_token: Some(\"test_token\".to_string()),\n refresh_token: Some(\"refresh_token\".to_string()),\n expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),\n };\n\n // Test unsupported OAuth service\n let result = validator\n .validate_oauth_credentials(ApiKeyType::OpenAI, &oauth_creds)\n .await;\n\n assert!(result.is_ok());\n let validation = result.unwrap();\n assert!(!validation.is_valid);\n assert!(validation.error_message.is_some());\n assert!(validation\n .error_message\n .unwrap()\n .contains(\"OAuth validation not supported\"));\n }\n\n #[test]\n fn test_extract_openai_rate_limits_empty() {\n let validator = create_test_validator();\n let headers = reqwest::header::HeaderMap::new();\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_none());\n }\n\n #[test]\n fn test_extract_openai_rate_limits_with_data() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"50\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(50));\n assert!(limits.reset_time.is_none());\n assert!(limits.daily_limit.is_none());\n }\n\n #[test]\n fn test_api_validator_default() {\n let _validator1 = ApiValidator::new();\n let _validator2 = ApiValidator::default();\n\n // Both should create valid instances\n // We can't compare them directly, but we can check they both work\n // Test passes if validation completes without panicking\n }\n\n #[test]\n fn test_api_key_type_matching() {\n // Test that all API key types are handled\n let types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::Grok,\n ApiKeyType::YouTube,\n ApiKeyType::TikTok,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n // Just ensure all types exist and can be matched\n for key_type in types {\n match key_type {\n ApiKeyType::OpenAI => {} // Valid API key type variant,\n ApiKeyType::Claude => {} // Valid API key type variant,\n ApiKeyType::DeepSeek => {} // Valid API key type variant,\n ApiKeyType::Grok => {} // Valid API key type variant,\n ApiKeyType::YouTube => {} // Valid API key type variant,\n ApiKeyType::TikTok => {} // Valid API key type variant,\n ApiKeyType::Vimeo => {} // Valid API key type variant,\n ApiKeyType::Telegram => {} // Valid API key type variant,\n ApiKeyType::Codecov => {} // Valid API key type variant,\n ApiKeyType::TauriAnalytics => {} // Valid API key type variant,\n ApiKeyType::MCPClaude => {} // Valid API key type variant,\n }\n }\n }\n\n #[tokio::test]\n async fn test_validate_openai_key_invalid() {\n // Note: In real tests, we would use mocks to avoid actual API calls\n // For now, we test the structure with an obviously invalid key\n let validator = create_test_validator();\n let result = validator.validate_openai_key(\"invalid_key_123\").await;\n\n // The result should be Ok (no network error), but validation should fail\n assert!(result.is_ok());\n let _validation = result.unwrap();\n // We expect this to fail since it's an invalid key\n // The actual behavior depends on network availability\n }\n\n #[test]\n fn test_oauth_credentials_structure() {\n let oauth = OAuthCredentials {\n client_id: \"client_123\".to_string(),\n client_secret: \"secret_456\".to_string(),\n access_token: Some(\"access_123\".to_string()),\n refresh_token: Some(\"refresh_456\".to_string()),\n expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),\n };\n\n assert_eq!(oauth.client_id, \"client_123\");\n assert_eq!(oauth.client_secret, \"secret_456\");\n assert_eq!(oauth.access_token, Some(\"access_123\".to_string()));\n assert_eq!(oauth.refresh_token, Some(\"refresh_456\".to_string()));\n assert!(oauth.expires_at.is_some());\n }\n\n #[test]\n fn test_validation_result_serialization() {\n let result = ValidationResult {\n is_valid: true,\n error_message: Some(\"Test error\".to_string()),\n service_info: Some(\"Service info\".to_string()),\n rate_limits: Some(RateLimitInfo {\n requests_remaining: Some(100),\n reset_time: None,\n daily_limit: Some(1000),\n }),\n };\n\n // Test that it can be serialized\n let serialized = serde_json::to_string(&result);\n assert!(serialized.is_ok());\n\n let json = serialized.unwrap();\n assert!(json.contains(\"is_valid\"));\n assert!(json.contains(\"error_message\"));\n assert!(json.contains(\"service_info\"));\n assert!(json.contains(\"rate_limits\"));\n }\n\n #[test]\n fn test_rate_limit_edge_cases() {\n // Test with all None values\n let rate_limit1 = RateLimitInfo {\n requests_remaining: None,\n reset_time: None,\n daily_limit: None,\n };\n assert!(rate_limit1.requests_remaining.is_none());\n assert!(rate_limit1.reset_time.is_none());\n assert!(rate_limit1.daily_limit.is_none());\n\n // Test with extreme values\n let rate_limit2 = RateLimitInfo {\n requests_remaining: Some(u32::MAX),\n reset_time: Some(chrono::DateTime::::MIN_UTC),\n daily_limit: Some(0),\n };\n assert_eq!(rate_limit2.requests_remaining, Some(u32::MAX));\n assert!(rate_limit2.reset_time.is_some());\n assert_eq!(rate_limit2.daily_limit, Some(0));\n }\n\n #[test]\n fn test_client_timeout() {\n // Test that client is created with timeout\n let _validator = ApiValidator::new();\n // The client is private, but we can verify it doesn't panic on creation\n {} // Valid API key type variant;\n }\n\n #[tokio::test]\n async fn test_validate_api_key_all_types() {\n let validator = create_test_validator();\n let test_key = \"test_key_123\";\n\n // Test that validate_api_key handles all key types without panicking\n let key_types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::YouTube,\n ApiKeyType::TikTok,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n for key_type in key_types {\n // We don't test actual validation (would require mocks),\n // just that the method handles all types\n let _ = validator.validate_api_key(key_type, test_key).await;\n }\n }\n\n #[test]\n fn test_extract_rate_limits_invalid_headers() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Add invalid header values\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"not-a-number\"),\n );\n headers.insert(\n \"x-ratelimit-reset-requests\",\n reqwest::header::HeaderValue::from_static(\"invalid-date\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n // Should return None when parsing fails\n assert!(rate_limits.is_none());\n }\n\n #[test]\n fn test_extract_rate_limits_partial_headers() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Only add remaining requests\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"100\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(100));\n assert!(limits.reset_time.is_none());\n }\n\n #[test]\n fn test_api_key_type_conversion() {\n // Test as_str conversion\n assert_eq!(ApiKeyType::OpenAI.as_str(), \"openai\");\n assert_eq!(ApiKeyType::Claude.as_str(), \"claude\");\n assert_eq!(ApiKeyType::DeepSeek.as_str(), \"deepseek\");\n assert_eq!(ApiKeyType::YouTube.as_str(), \"youtube\");\n assert_eq!(ApiKeyType::TikTok.as_str(), \"tiktok\");\n assert_eq!(ApiKeyType::Vimeo.as_str(), \"vimeo\");\n assert_eq!(ApiKeyType::Telegram.as_str(), \"telegram\");\n assert_eq!(ApiKeyType::Codecov.as_str(), \"codecov\");\n assert_eq!(ApiKeyType::TauriAnalytics.as_str(), \"tauri_analytics\");\n }\n\n #[test]\n fn test_api_key_type_from_str() {\n use std::str::FromStr;\n\n // Test valid conversions\n assert_eq!(ApiKeyType::from_str(\"openai\").unwrap(), ApiKeyType::OpenAI);\n assert_eq!(ApiKeyType::from_str(\"claude\").unwrap(), ApiKeyType::Claude);\n assert_eq!(\n ApiKeyType::from_str(\"deepseek\").unwrap(),\n ApiKeyType::DeepSeek\n );\n assert_eq!(\n ApiKeyType::from_str(\"youtube\").unwrap(),\n ApiKeyType::YouTube\n );\n assert_eq!(ApiKeyType::from_str(\"tiktok\").unwrap(), ApiKeyType::TikTok);\n assert_eq!(ApiKeyType::from_str(\"vimeo\").unwrap(), ApiKeyType::Vimeo);\n assert_eq!(\n ApiKeyType::from_str(\"telegram\").unwrap(),\n ApiKeyType::Telegram\n );\n assert_eq!(\n ApiKeyType::from_str(\"codecov\").unwrap(),\n ApiKeyType::Codecov\n );\n assert_eq!(\n ApiKeyType::from_str(\"tauri_analytics\").unwrap(),\n ApiKeyType::TauriAnalytics\n );\n\n // Test invalid conversion\n assert!(ApiKeyType::from_str(\"invalid\").is_err());\n }\n\n #[tokio::test]\n async fn test_validate_unsupported_service_for_oauth() {\n let validator = create_test_validator();\n\n // Test validation for a service that doesn't support simple key validation\n let result = validator\n .validate_api_key(ApiKeyType::YouTube, \"some-key\")\n .await;\n\n assert!(result.is_ok());\n let validation = result.unwrap();\n assert!(!validation.is_valid);\n assert!(validation.error_message.is_some());\n assert!(validation.error_message.unwrap().contains(\"OAuth\"));\n }\n\n #[test]\n fn test_extract_deepseek_rate_limits() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Add DeepSeek specific headers (similar to OpenAI)\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"30\"),\n );\n\n // DeepSeek uses similar headers to OpenAI\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(30));\n }\n\n #[tokio::test]\n async fn test_validate_multiple_key_types() {\n let validator = create_test_validator();\n\n // Test that all key types can be validated without panic\n let key_types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n for key_type in key_types {\n let result = validator.validate_api_key(key_type, \"test-key\").await;\n assert!(result.is_ok());\n }\n }\n\n #[test]\n fn test_oauth_credentials_with_expiry() {\n let expires = chrono::Utc::now() + chrono::Duration::hours(2);\n let oauth_creds = OAuthCredentials {\n client_id: \"test_client\".to_string(),\n client_secret: \"test_secret\".to_string(),\n access_token: Some(\"test_token\".to_string()),\n refresh_token: Some(\"refresh_token\".to_string()),\n expires_at: Some(expires),\n };\n\n assert_eq!(oauth_creds.client_id, \"test_client\");\n assert_eq!(oauth_creds.client_secret, \"test_secret\");\n assert!(oauth_creds.access_token.is_some());\n assert!(oauth_creds.refresh_token.is_some());\n assert!(oauth_creds.expires_at.is_some());\n assert!(oauth_creds.expires_at.unwrap() > chrono::Utc::now());\n }\n\n #[tokio::test]\n async fn test_validate_claude_key_call() {\n let validator = create_test_validator();\n\n // Test that Claude validation can be called without panic\n let result = validator\n .validate_api_key(ApiKeyType::Claude, \"sk-ant-test\")\n .await;\n assert!(result.is_ok());\n }\n}\n"}, "763684af87be3d31e891673d37e55b3cf2987430": {"t": 1785756930.6836662, "n": 33887, "h": "5b0b2606e600c710", "c": "use super::{ApiKeyType, OAuthCredentials};\nuse anyhow::Result;\nuse reqwest::Client;\nuse serde_json::{json, Value};\nuse std::time::Duration;\n\n/// Результат валидации API ключа\n#[derive(Debug, Clone, serde::Serialize)]\npub struct ValidationResult {\n pub is_valid: bool,\n pub error_message: Option,\n pub service_info: Option, // Дополнительная информация о сервисе\n pub rate_limits: Option,\n}\n\n/// Информация о лимитах API\n#[derive(Debug, Clone, serde::Serialize)]\npub struct RateLimitInfo {\n pub requests_remaining: Option,\n pub reset_time: Option>,\n pub daily_limit: Option,\n}\n\n/// Валидатор API ключей\npub struct ApiValidator {\n client: Client,\n}\n\nimpl ApiValidator {\n pub fn new() -> Self {\n let client = Client::builder()\n .timeout(Duration::from_secs(10))\n .build()\n .expect(\"Failed to create HTTP client\");\n\n Self { client }\n }\n\n /// Валидирует API ключ в зависимости от типа сервиса\n pub async fn validate_api_key(\n &self,\n key_type: ApiKeyType,\n value: &str,\n ) -> Result {\n match key_type {\n ApiKeyType::OpenAI => self.validate_openai_key(value).await,\n ApiKeyType::Claude => self.validate_claude_key(value).await,\n ApiKeyType::MCPClaude => self.validate_claude_key(value).await, // MCP использует тот же Claude API\n ApiKeyType::DeepSeek => self.validate_deepseek_key(value).await,\n ApiKeyType::Grok => self.validate_grok_key(value).await,\n ApiKeyType::YouTube => self.validate_youtube_oauth(value).await,\n ApiKeyType::TikTok => self.validate_tiktok_oauth(value).await,\n ApiKeyType::Vimeo => self.validate_vimeo_key(value).await,\n ApiKeyType::Telegram => self.validate_telegram_bot(value).await,\n ApiKeyType::Codecov => self.validate_codecov_token(value).await,\n ApiKeyType::TauriAnalytics => self.validate_tauri_analytics(value).await,\n }\n }\n\n /// Валидирует OAuth credentials\n pub async fn validate_oauth_credentials(\n &self,\n key_type: ApiKeyType,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n match key_type {\n ApiKeyType::YouTube => self.validate_youtube_oauth_full(oauth_data).await,\n ApiKeyType::TikTok => self.validate_tiktok_oauth_full(oauth_data).await,\n ApiKeyType::Vimeo => self.validate_vimeo_oauth_full(oauth_data).await,\n _ => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"OAuth validation not supported for this service\".to_string()),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // OpenAI API валидация\n async fn validate_openai_key(&self, api_key: &str) -> Result {\n let response = self\n .client\n .get(\"https://api.openai.com/v1/models\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n let status = resp.status();\n let headers = resp.headers().clone();\n\n if status.is_success() {\n let rate_limits = self.extract_openai_rate_limits(&headers);\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"OpenAI API access confirmed\".to_string()),\n rate_limits,\n })\n } else if status.as_u16() == 401 {\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message = if error_text.contains(\"insufficient_quota\")\n || error_text.contains(\"quota_exceeded\")\n || error_text.contains(\"billing_hard_limit\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://platform.openai.com/account/billing\"\n .to_string()\n } else {\n \"Неверный API ключ\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else if status.as_u16() == 429 {\n let error_text = resp.text().await.unwrap_or_default();\n let error_message = if error_text.contains(\"quota\") || error_text.contains(\"billing\") {\n \"Недостаточно кредитов. Пополните баланс на https://platform.openai.com/account/billing\"\n .to_string()\n } else {\n \"Превышен лимит запросов\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else {\n let error_text = resp.text().await.unwrap_or_default();\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"OpenAI API error {status}: {error_text}\")),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Claude API валидация\n async fn validate_claude_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к Claude API\n let body = json!({\n \"model\": \"claude-3-haiku-20240307\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.anthropic.com/v1/messages\")\n .header(\"x-api-key\", api_key)\n .header(\"anthropic-version\", \"2023-06-01\")\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n if status.is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Claude API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else if status.as_u16() == 401 {\n // Проверяем специфические ошибки для ключа\n let error_message = if error_text.contains(\"credit balance is too low\")\n || error_text.contains(\"Insufficient credits\")\n || error_text.contains(\"quota exceeded\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://console.anthropic.com/billing\"\n .to_string()\n } else {\n \"Неверный API ключ\".to_string()\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else if status.as_u16() == 400 {\n // Проверяем ошибки 400, включая недостаточный баланс\n let error_message = if error_text.contains(\"credit balance is too low\")\n || error_text.contains(\"Insufficient credits\")\n || error_text.contains(\"quota exceeded\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://console.anthropic.com/billing\"\n .to_string()\n } else {\n format!(\"Claude API error {}: {}\", status, error_text)\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: None,\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Claude API error {}: {}\", status, error_text)),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_deepseek_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к DeepSeek API\n let body = json!({\n \"model\": \"deepseek-chat\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.deepseek.com/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"DeepSeek API\".to_string()),\n rate_limits: None,\n })\n } else {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message = if error_text.contains(\"invalid api key\") {\n \"Неверный API ключ\".to_string()\n } else if error_text.contains(\"insufficient_balance\")\n || error_text.contains(\"insufficient_quota\")\n || error_text.contains(\"quota_exceeded\")\n || error_text.contains(\"Insufficient Balance\")\n {\n \"Недостаточно кредитов. Пополните баланс на https://platform.deepseek.com/usage\"\n .to_string()\n } else {\n format!(\"DeepSeek API error {status}: {error_text}\")\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: Some(\"DeepSeek API\".to_string()),\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_grok_key(&self, api_key: &str) -> Result {\n // Простой тест запрос к Grok API\n let body = json!({\n \"model\": \"grok-beta\",\n \"max_tokens\": 10,\n \"messages\": [{\"role\": \"user\", \"content\": \"Hi\"}]\n });\n\n let response = self\n .client\n .post(\"https://api.x.ai/v1/chat/completions\")\n .header(\"Authorization\", format!(\"Bearer {api_key}\"))\n .header(\"content-type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Grok API\".to_string()),\n rate_limits: None,\n })\n } else {\n let status = resp.status();\n let error_text = resp.text().await.unwrap_or_default();\n\n // Проверяем специфические ошибки\n let error_message =\n if error_text.contains(\"does not have permission\") && error_text.contains(\"credits\") {\n \"Недостаточно кредитов. Пожалуйста, пополните баланс на https://console.x.ai\"\n .to_string()\n } else if error_text.contains(\"Incorrect API key\") {\n \"Неверный API ключ\".to_string()\n } else {\n format!(\"Grok API error {status}: {error_text}\")\n };\n\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(error_message),\n service_info: Some(\"Grok API\".to_string()),\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // YouTube OAuth валидация (проверка client_id:client_secret)\n async fn validate_youtube_oauth(&self, _credentials: &str) -> Result {\n // Для YouTube нужна полная OAuth валидация\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Use OAuth credentials validation for YouTube\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n // YouTube OAuth full валидация\n async fn validate_youtube_oauth_full(\n &self,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n if let Some(access_token) = &oauth_data.access_token {\n // Проверяем access token через YouTube API\n let response = self\n .client\n .get(\"https://www.googleapis.com/youtube/v3/channels\")\n .query(&[(\"part\", \"snippet\"), (\"mine\", \"true\")])\n .header(\"Authorization\", format!(\"Bearer {access_token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"YouTube API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid or expired access token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n } else {\n // Проверяем client credentials через OAuth2 endpoint\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"No access token available, OAuth flow required\".to_string()),\n service_info: Some(\"Client credentials present\".to_string()),\n rate_limits: None,\n })\n }\n }\n\n // TikTok OAuth валидация\n async fn validate_tiktok_oauth(&self, _credentials: &str) -> Result {\n // TikTok API валидация - пока заглушка\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"TikTok API validation not implemented yet\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n async fn validate_tiktok_oauth_full(\n &self,\n _oauth_data: &OAuthCredentials,\n ) -> Result {\n // TikTok OAuth валидация - пока заглушка\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"TikTok OAuth validation not implemented yet\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n\n // Vimeo API валидация\n async fn validate_vimeo_key(&self, access_token: &str) -> Result {\n let response = self\n .client\n .get(\"https://api.vimeo.com/me\")\n .header(\"Authorization\", format!(\"Bearer {access_token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Vimeo API access confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid access token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n async fn validate_vimeo_oauth_full(\n &self,\n oauth_data: &OAuthCredentials,\n ) -> Result {\n if let Some(access_token) = &oauth_data.access_token {\n self.validate_vimeo_key(access_token).await\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"No access token available\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n\n // Telegram Bot валидация\n async fn validate_telegram_bot(&self, bot_token: &str) -> Result {\n let url = format!(\"https://api.telegram.org/bot{bot_token}/getMe\");\n let response = self.client.get(&url).send().await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n let json: Value = resp.json().await.unwrap_or_default();\n if json[\"ok\"].as_bool().unwrap_or(false) {\n let bot_name = json[\"result\"][\"username\"].as_str().unwrap_or(\"Unknown\");\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(format!(\"Telegram bot @{bot_name} confirmed\")),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid bot token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid bot token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Codecov token валидация\n async fn validate_codecov_token(&self, token: &str) -> Result {\n let response = self\n .client\n .get(\"https://codecov.io/api/v2/user\")\n .header(\"Authorization\", format!(\"token {token}\"))\n .send()\n .await;\n\n match response {\n Ok(resp) => {\n if resp.status().is_success() {\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Codecov token confirmed\".to_string()),\n rate_limits: None,\n })\n } else {\n Ok(ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid Codecov token\".to_string()),\n service_info: None,\n rate_limits: None,\n })\n }\n }\n Err(e) => Ok(ValidationResult {\n is_valid: false,\n error_message: Some(format!(\"Network error: {e}\")),\n service_info: None,\n rate_limits: None,\n }),\n }\n }\n\n // Tauri Analytics валидация (заглушка)\n async fn validate_tauri_analytics(&self, _key: &str) -> Result {\n // Tauri Analytics пока не имеет публичного API для валидации\n Ok(ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Tauri Analytics key format appears valid\".to_string()),\n rate_limits: None,\n })\n }\n\n // Извлечение rate limit информации из OpenAI headers\n fn extract_openai_rate_limits(\n &self,\n headers: &reqwest::header::HeaderMap,\n ) -> Option {\n let requests_remaining = headers\n .get(\"x-ratelimit-remaining-requests\")\n .and_then(|v| v.to_str().ok())\n .and_then(|s| s.parse().ok());\n\n let reset_time = headers\n .get(\"x-ratelimit-reset-requests\")\n .and_then(|v| v.to_str().ok())\n .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())\n .map(|dt| dt.with_timezone(&chrono::Utc));\n\n if requests_remaining.is_some() || reset_time.is_some() {\n Some(RateLimitInfo {\n requests_remaining,\n reset_time,\n daily_limit: None,\n })\n } else {\n None\n }\n }\n}\n\nimpl Default for ApiValidator {\n fn default() -> Self {\n Self::new()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn create_test_validator() -> ApiValidator {\n ApiValidator::new()\n }\n\n #[test]\n fn test_validation_result_creation() {\n let result = ValidationResult {\n is_valid: true,\n error_message: None,\n service_info: Some(\"Test service\".to_string()),\n rate_limits: None,\n };\n\n assert!(result.is_valid);\n assert!(result.error_message.is_none());\n assert_eq!(result.service_info, Some(\"Test service\".to_string()));\n assert!(result.rate_limits.is_none());\n }\n\n #[test]\n fn test_rate_limit_info() {\n let rate_limit = RateLimitInfo {\n requests_remaining: Some(100),\n reset_time: Some(chrono::Utc::now()),\n daily_limit: Some(1000),\n };\n\n assert_eq!(rate_limit.requests_remaining, Some(100));\n assert!(rate_limit.reset_time.is_some());\n assert_eq!(rate_limit.daily_limit, Some(1000));\n }\n\n #[test]\n fn test_validation_result_with_error() {\n let result = ValidationResult {\n is_valid: false,\n error_message: Some(\"Invalid API key\".to_string()),\n service_info: None,\n rate_limits: None,\n };\n\n assert!(!result.is_valid);\n assert_eq!(result.error_message, Some(\"Invalid API key\".to_string()));\n assert!(result.service_info.is_none());\n assert!(result.rate_limits.is_none());\n }\n\n #[test]\n fn test_rate_limit_info_partial() {\n let rate_limit = RateLimitInfo {\n requests_remaining: Some(50),\n reset_time: None,\n daily_limit: None,\n };\n\n assert_eq!(rate_limit.requests_remaining, Some(50));\n assert!(rate_limit.reset_time.is_none());\n assert!(rate_limit.daily_limit.is_none());\n }\n\n #[tokio::test]\n async fn test_validate_api_key_unsupported_oauth() {\n let validator = create_test_validator();\n let oauth_creds = OAuthCredentials {\n client_id: \"client_123\".to_string(),\n client_secret: \"secret_456\".to_string(),\n access_token: Some(\"test_token\".to_string()),\n refresh_token: Some(\"refresh_token\".to_string()),\n expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),\n };\n\n // Test unsupported OAuth service\n let result = validator\n .validate_oauth_credentials(ApiKeyType::OpenAI, &oauth_creds)\n .await;\n\n assert!(result.is_ok());\n let validation = result.unwrap();\n assert!(!validation.is_valid);\n assert!(validation.error_message.is_some());\n assert!(validation\n .error_message\n .unwrap()\n .contains(\"OAuth validation not supported\"));\n }\n\n #[test]\n fn test_extract_openai_rate_limits_empty() {\n let validator = create_test_validator();\n let headers = reqwest::header::HeaderMap::new();\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_none());\n }\n\n #[test]\n fn test_extract_openai_rate_limits_with_data() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"50\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(50));\n assert!(limits.reset_time.is_none());\n assert!(limits.daily_limit.is_none());\n }\n\n #[test]\n fn test_api_validator_default() {\n let _validator1 = ApiValidator::new();\n let _validator2 = ApiValidator::default();\n\n // Both should create valid instances\n // We can't compare them directly, but we can check they both work\n // Test passes if validation completes without panicking\n }\n\n #[test]\n fn test_api_key_type_matching() {\n // Test that all API key types are handled\n let types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::Grok,\n ApiKeyType::YouTube,\n ApiKeyType::TikTok,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n // Just ensure all types exist and can be matched\n for key_type in types {\n match key_type {\n ApiKeyType::OpenAI => {} // Valid API key type variant,\n ApiKeyType::Claude => {} // Valid API key type variant,\n ApiKeyType::DeepSeek => {} // Valid API key type variant,\n ApiKeyType::Grok => {} // Valid API key type variant,\n ApiKeyType::YouTube => {} // Valid API key type variant,\n ApiKeyType::TikTok => {} // Valid API key type variant,\n ApiKeyType::Vimeo => {} // Valid API key type variant,\n ApiKeyType::Telegram => {} // Valid API key type variant,\n ApiKeyType::Codecov => {} // Valid API key type variant,\n ApiKeyType::TauriAnalytics => {} // Valid API key type variant,\n ApiKeyType::MCPClaude => {} // Valid API key type variant,\n }\n }\n }\n\n #[tokio::test]\n async fn test_validate_openai_key_invalid() {\n // Note: In real tests, we would use mocks to avoid actual API calls\n // For now, we test the structure with an obviously invalid key\n let validator = create_test_validator();\n let result = validator.validate_openai_key(\"invalid_key_123\").await;\n\n // The result should be Ok (no network error), but validation should fail\n assert!(result.is_ok());\n let _validation = result.unwrap();\n // We expect this to fail since it's an invalid key\n // The actual behavior depends on network availability\n }\n\n #[test]\n fn test_oauth_credentials_structure() {\n let oauth = OAuthCredentials {\n client_id: \"client_123\".to_string(),\n client_secret: \"secret_456\".to_string(),\n access_token: Some(\"access_123\".to_string()),\n refresh_token: Some(\"refresh_456\".to_string()),\n expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),\n };\n\n assert_eq!(oauth.client_id, \"client_123\");\n assert_eq!(oauth.client_secret, \"secret_456\");\n assert_eq!(oauth.access_token, Some(\"access_123\".to_string()));\n assert_eq!(oauth.refresh_token, Some(\"refresh_456\".to_string()));\n assert!(oauth.expires_at.is_some());\n }\n\n #[test]\n fn test_validation_result_serialization() {\n let result = ValidationResult {\n is_valid: true,\n error_message: Some(\"Test error\".to_string()),\n service_info: Some(\"Service info\".to_string()),\n rate_limits: Some(RateLimitInfo {\n requests_remaining: Some(100),\n reset_time: None,\n daily_limit: Some(1000),\n }),\n };\n\n // Test that it can be serialized\n let serialized = serde_json::to_string(&result);\n assert!(serialized.is_ok());\n\n let json = serialized.unwrap();\n assert!(json.contains(\"is_valid\"));\n assert!(json.contains(\"error_message\"));\n assert!(json.contains(\"service_info\"));\n assert!(json.contains(\"rate_limits\"));\n }\n\n #[test]\n fn test_rate_limit_edge_cases() {\n // Test with all None values\n let rate_limit1 = RateLimitInfo {\n requests_remaining: None,\n reset_time: None,\n daily_limit: None,\n };\n assert!(rate_limit1.requests_remaining.is_none());\n assert!(rate_limit1.reset_time.is_none());\n assert!(rate_limit1.daily_limit.is_none());\n\n // Test with extreme values\n let rate_limit2 = RateLimitInfo {\n requests_remaining: Some(u32::MAX),\n reset_time: Some(chrono::DateTime::::MIN_UTC),\n daily_limit: Some(0),\n };\n assert_eq!(rate_limit2.requests_remaining, Some(u32::MAX));\n assert!(rate_limit2.reset_time.is_some());\n assert_eq!(rate_limit2.daily_limit, Some(0));\n }\n\n #[test]\n fn test_client_timeout() {\n // Test that client is created with timeout\n let _validator = ApiValidator::new();\n // The client is private, but we can verify it doesn't panic on creation\n {} // Valid API key type variant;\n }\n\n #[tokio::test]\n async fn test_validate_api_key_all_types() {\n let validator = create_test_validator();\n let test_key = \"test_key_123\";\n\n // Test that validate_api_key handles all key types without panicking\n let key_types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::YouTube,\n ApiKeyType::TikTok,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n for key_type in key_types {\n // We don't test actual validation (would require mocks),\n // just that the method handles all types\n let _ = validator.validate_api_key(key_type, test_key).await;\n }\n }\n\n #[test]\n fn test_extract_rate_limits_invalid_headers() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Add invalid header values\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"not-a-number\"),\n );\n headers.insert(\n \"x-ratelimit-reset-requests\",\n reqwest::header::HeaderValue::from_static(\"invalid-date\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n // Should return None when parsing fails\n assert!(rate_limits.is_none());\n }\n\n #[test]\n fn test_extract_rate_limits_partial_headers() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Only add remaining requests\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"100\"),\n );\n\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(100));\n assert!(limits.reset_time.is_none());\n }\n\n #[test]\n fn test_api_key_type_conversion() {\n // Test as_str conversion\n assert_eq!(ApiKeyType::OpenAI.as_str(), \"openai\");\n assert_eq!(ApiKeyType::Claude.as_str(), \"claude\");\n assert_eq!(ApiKeyType::DeepSeek.as_str(), \"deepseek\");\n assert_eq!(ApiKeyType::YouTube.as_str(), \"youtube\");\n assert_eq!(ApiKeyType::TikTok.as_str(), \"tiktok\");\n assert_eq!(ApiKeyType::Vimeo.as_str(), \"vimeo\");\n assert_eq!(ApiKeyType::Telegram.as_str(), \"telegram\");\n assert_eq!(ApiKeyType::Codecov.as_str(), \"codecov\");\n assert_eq!(ApiKeyType::TauriAnalytics.as_str(), \"tauri_analytics\");\n }\n\n #[test]\n fn test_api_key_type_from_str() {\n use std::str::FromStr;\n\n // Test valid conversions\n assert_eq!(ApiKeyType::from_str(\"openai\").unwrap(), ApiKeyType::OpenAI);\n assert_eq!(ApiKeyType::from_str(\"claude\").unwrap(), ApiKeyType::Claude);\n assert_eq!(\n ApiKeyType::from_str(\"deepseek\").unwrap(),\n ApiKeyType::DeepSeek\n );\n assert_eq!(\n ApiKeyType::from_str(\"youtube\").unwrap(),\n ApiKeyType::YouTube\n );\n assert_eq!(ApiKeyType::from_str(\"tiktok\").unwrap(), ApiKeyType::TikTok);\n assert_eq!(ApiKeyType::from_str(\"vimeo\").unwrap(), ApiKeyType::Vimeo);\n assert_eq!(\n ApiKeyType::from_str(\"telegram\").unwrap(),\n ApiKeyType::Telegram\n );\n assert_eq!(\n ApiKeyType::from_str(\"codecov\").unwrap(),\n ApiKeyType::Codecov\n );\n assert_eq!(\n ApiKeyType::from_str(\"tauri_analytics\").unwrap(),\n ApiKeyType::TauriAnalytics\n );\n\n // Test invalid conversion\n assert!(ApiKeyType::from_str(\"invalid\").is_err());\n }\n\n #[tokio::test]\n async fn test_validate_unsupported_service_for_oauth() {\n let validator = create_test_validator();\n\n // Test validation for a service that doesn't support simple key validation\n let result = validator\n .validate_api_key(ApiKeyType::YouTube, \"some-key\")\n .await;\n\n assert!(result.is_ok());\n let validation = result.unwrap();\n assert!(!validation.is_valid);\n assert!(validation.error_message.is_some());\n assert!(validation.error_message.unwrap().contains(\"OAuth\"));\n }\n\n #[test]\n fn test_extract_deepseek_rate_limits() {\n let validator = create_test_validator();\n let mut headers = reqwest::header::HeaderMap::new();\n\n // Add DeepSeek specific headers (similar to OpenAI)\n headers.insert(\n \"x-ratelimit-remaining-requests\",\n reqwest::header::HeaderValue::from_static(\"30\"),\n );\n\n // DeepSeek uses similar headers to OpenAI\n let rate_limits = validator.extract_openai_rate_limits(&headers);\n assert!(rate_limits.is_some());\n let limits = rate_limits.unwrap();\n assert_eq!(limits.requests_remaining, Some(30));\n }\n\n #[tokio::test]\n async fn test_validate_multiple_key_types() {\n let validator = create_test_validator();\n\n // Test that all key types can be validated without panic\n let key_types = vec![\n ApiKeyType::OpenAI,\n ApiKeyType::Claude,\n ApiKeyType::DeepSeek,\n ApiKeyType::Vimeo,\n ApiKeyType::Telegram,\n ApiKeyType::Codecov,\n ApiKeyType::TauriAnalytics,\n ];\n\n for key_type in key_types {\n let result = validator.validate_api_key(key_type, \"test-key\").await;\n assert!(result.is_ok());\n }\n }\n\n #[test]\n fn test_oauth_credentials_with_expiry() {\n let expires = chrono::Utc::now() + chrono::Duration::hours(2);\n let oauth_creds = OAuthCredentials {\n client_id: \"test_client\".to_string(),\n client_secret: \"test_secret\".to_string(),\n access_token: Some(\"test_token\".to_string()),\n refresh_token: Some(\"refresh_token\".to_string()),\n expires_at: Some(expires),\n };\n\n assert_eq!(oauth_creds.client_id, \"test_client\");\n assert_eq!(oauth_creds.client_secret, \"test_secret\");\n assert!(oauth_creds.access_token.is_some());\n assert!(oauth_creds.refresh_token.is_some());\n assert!(oauth_creds.expires_at.is_some());\n assert!(oauth_creds.expires_at.unwrap() > chrono::Utc::now());\n }\n\n #[tokio::test]\n async fn test_validate_claude_key_call() {\n let validator = create_test_validator();\n\n // Test that Claude validation can be called without panic\n let result = validator\n .validate_api_key(ApiKeyType::Claude, \"sk-ant-test\")\n .await;\n assert!(result.is_ok());\n }\n}\n"}}, "crates/ts-render-tauri/src/ai_api_proxy/types.rs": {"9253fded22485cb087ef100842c6be6a8989dfc4": {"t": 1785746233.7692902, "n": 18186, "h": "6910430175373663", "c": "use serde::{Deserialize, Serialize};\nuse specta::Type;\n\n// ============================================================================\n// UNIFIED AI PROVIDER TYPES\n// ============================================================================\n\n/// AI Provider Type\n#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"lowercase\")]\npub enum AIProvider {\n Claude,\n OpenAI,\n DeepSeek,\n Ollama,\n}\n\n/// AI Tool Definition (Claude Tool Use / OpenAI Function Calling)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct AITool {\n /// Tool name\n pub name: String,\n\n /// Tool description\n pub description: String,\n\n /// Input schema (JSON Schema)\n pub input_schema: serde_json::Value,\n}\n\n/// AI Tool Call (when AI decides to use a tool)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIToolCall {\n /// Tool call ID\n pub id: String,\n\n /// Tool name\n pub name: String,\n\n /// Tool input (JSON)\n pub input: serde_json::Value,\n}\n\n/// Tool Choice Strategy\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum ToolChoice {\n /// AI decides whether to use tools\n Auto,\n\n /// Force AI to use a specific tool\n Required,\n\n /// Specific tool to use\n Tool { name: String },\n\n /// Don't use tools\n None,\n}\n\n/// Unified AI Request (works with all providers)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct UnifiedAIRequest {\n pub provider: AIProvider,\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n pub system: Option,\n /// 🆕 Tools для Function Calling / Claude Tool Use\n pub tools: Option>,\n /// 🆕 Tool choice strategy\n pub tool_choice: Option,\n}\n\n/// AI Message Content (supports text and images)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(untagged)]\npub enum AIMessageContent {\n /// Simple text message\n Text(String),\n\n /// Multimodal message with text and/or images\n Multimodal(Vec),\n}\n\n/// Content part (text or image)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AIContentPart {\n /// Text content\n Text { text: String },\n\n /// Image content (base64 or URL)\n Image {\n #[serde(flatten)]\n source: AIImageSource,\n },\n}\n\n/// Image source (base64 or URL)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AIImageSource {\n /// Base64 encoded image\n Base64 {\n #[serde(rename = \"media_type\")]\n media_type: String, // e.g., \"image/jpeg\", \"image/png\"\n data: String, // base64 encoded\n },\n\n /// Image URL\n Url { url: String },\n}\n\n/// Unified AI Message (supports text and vision)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct AIMessage {\n pub role: String,\n pub content: AIMessageContent,\n}\n\nimpl AIMessage {\n /// Create a simple text message\n pub fn text(role: impl Into, text: impl Into) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Text(text.into()),\n }\n }\n\n /// Create a message with image (base64)\n pub fn with_image_base64(\n role: impl Into,\n text: impl Into,\n image_data: String,\n media_type: impl Into,\n ) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Multimodal(vec![\n AIContentPart::Text { text: text.into() },\n AIContentPart::Image {\n source: AIImageSource::Base64 {\n media_type: media_type.into(),\n data: image_data,\n },\n },\n ]),\n }\n }\n\n /// Create a message with image URL\n pub fn with_image_url(\n role: impl Into,\n text: impl Into,\n image_url: impl Into,\n ) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Multimodal(vec![\n AIContentPart::Text { text: text.into() },\n AIContentPart::Image {\n source: AIImageSource::Url {\n url: image_url.into(),\n },\n },\n ]),\n }\n }\n\n /// Get text content from message\n pub fn get_text(&self) -> String {\n match &self.content {\n AIMessageContent::Text(text) => text.clone(),\n AIMessageContent::Multimodal(parts) => parts\n .iter()\n .filter_map(|part| {\n if let AIContentPart::Text { text } = part {\n Some(text.as_str())\n } else {\n None\n }\n })\n .collect::>()\n .join(\"\\n\"),\n }\n }\n}\n\n/// Unified AI Response\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct UnifiedAIResponse {\n pub id: String,\n pub provider: AIProvider,\n pub model: String,\n pub content: String,\n pub usage: Option,\n pub finish_reason: Option,\n /// 🆕 Tool calls если AI вызвал инструменты\n pub tool_calls: Option>,\n}\n\n/// Token Usage (unified across providers)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct TokenUsage {\n pub input_tokens: u32,\n pub output_tokens: u32,\n pub total_tokens: u32,\n}\n\n// ============================================================================\n// CLAUDE-SPECIFIC TYPES (legacy compatibility)\n// ============================================================================\n\n/// Запрос к Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeApiRequest {\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n pub system: Option,\n}\n\n/// Сообщение для Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeMessage {\n pub role: String,\n pub content: String,\n}\n\n/// Ответ от Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeApiResponse {\n pub id: String,\n pub model: String,\n pub content: Vec,\n pub usage: Option,\n}\n\n/// Контент от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeContent {\n pub r#type: String,\n pub text: Option,\n}\n\n/// Использование токенов\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeUsage {\n pub input_tokens: u32,\n pub output_tokens: u32,\n}\n\n/// Потоковый ответ от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeStreamChunk {\n pub r#type: String,\n pub delta: Option,\n pub message: Option,\n}\n\n/// Дельта изменения в потоке\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeDelta {\n pub r#type: String,\n pub text: Option,\n}\n\n// ============================================================================\n// OPENAI-SPECIFIC TYPES\n// ============================================================================\n\n/// OpenAI API Request\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIRequest {\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n}\n\n/// OpenAI Message Content (supports text and vision)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum OpenAIMessageContent {\n /// Simple text content (backward compatible)\n Text(String),\n /// Multimodal content array (text + images for vision models)\n Array(Vec),\n}\n\n/// OpenAI Content Part (for multimodal messages)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum OpenAIContentPart {\n /// Text content part\n Text { text: String },\n /// Image URL content part (for vision models like GPT-4o, GPT-4 Turbo, DeepSeek-VL)\n ImageUrl { image_url: OpenAIImageUrl },\n}\n\n/// OpenAI Image URL (supports base64 data URIs)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIImageUrl {\n /// Image URL (can be data:image/jpeg;base64,... or https://...)\n pub url: String,\n}\n\n/// OpenAI Message\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIMessage {\n pub role: String,\n pub content: OpenAIMessageContent,\n}\n\n/// OpenAI Response\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIResponse {\n pub id: String,\n pub object: String,\n pub created: u64,\n pub model: String,\n pub choices: Vec,\n pub usage: Option,\n}\n\n/// OpenAI Choice\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIChoice {\n pub index: u32,\n pub message: OpenAIMessage,\n pub finish_reason: Option,\n}\n\n/// OpenAI Usage\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIUsage {\n pub prompt_tokens: u32,\n pub completion_tokens: u32,\n pub total_tokens: u32,\n}\n\n// ============================================================================\n// DEEPSEEK-SPECIFIC TYPES\n// ============================================================================\n\n/// DeepSeek uses OpenAI-compatible API\npub type DeepSeekRequest = OpenAIRequest;\npub type DeepSeekResponse = OpenAIResponse;\npub type DeepSeekMessage = OpenAIMessage;\n\n// ============================================================================\n// OLLAMA-SPECIFIC TYPES\n// ============================================================================\n\n/// Ollama API Request\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaRequest {\n pub model: String,\n pub messages: Vec,\n pub stream: Option,\n pub options: Option,\n}\n\n/// Ollama Message\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaMessage {\n pub role: String,\n pub content: String,\n /// Base64 encoded images for vision models (llama3.2-vision, moondream2, llava)\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub images: Option>,\n}\n\n/// Ollama Options\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaOptions {\n pub temperature: Option,\n pub num_predict: Option, // equivalent to max_tokens\n}\n\n/// Ollama Response\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaResponse {\n pub model: String,\n pub created_at: String,\n pub message: OllamaMessage,\n pub done: bool,\n}\n\n// ============================================================================\n// VALIDATION TYPES\n// ============================================================================\n\n/// Запрос на валидацию API ключа\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct ValidateApiKeyRequest {\n pub api_key: String,\n pub provider: AIProvider,\n}\n\n/// Результат валидации\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct ValidateApiKeyResponse {\n pub valid: bool,\n pub message: String,\n pub models: Option>,\n}\n\n// ============================================================================\n// PROVIDER CONFIG\n// ============================================================================\n\n/// AI Provider Configuration\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderConfig {\n pub provider: AIProvider,\n pub api_key: Option,\n pub base_url: Option,\n pub default_model: Option,\n pub timeout: Option, // seconds\n}\n\n/// Provider Status\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderStatus {\n pub provider: AIProvider,\n pub available: bool,\n pub models: Vec,\n pub error: Option,\n}\n\n// ============================================================================\n// CONVERSION IMPLEMENTATIONS\n// ============================================================================\n\nimpl From for AIMessage {\n fn from(msg: ClaudeMessage) -> Self {\n AIMessage {\n role: msg.role,\n content: AIMessageContent::Text(msg.content),\n }\n }\n}\n\nimpl From for ClaudeMessage {\n fn from(msg: AIMessage) -> Self {\n let text = msg.get_text();\n ClaudeMessage {\n role: msg.role,\n content: text,\n }\n }\n}\n\nimpl From for AIMessage {\n fn from(msg: OpenAIMessage) -> Self {\n match msg.content {\n OpenAIMessageContent::Text(text) => AIMessage::text(msg.role, text),\n OpenAIMessageContent::Array(parts) => {\n let mut content_parts = Vec::new();\n for part in parts {\n match part {\n OpenAIContentPart::Text { text } => {\n content_parts.push(AIContentPart::Text { text });\n }\n OpenAIContentPart::ImageUrl { image_url } => {\n // Extract base64 from data URI (data:image/jpeg;base64,...)\n if let Some(base64_data) = image_url.url.strip_prefix(\"data:\") {\n if let Some(comma_pos) = base64_data.find(',') {\n let (media_type, data) = base64_data.split_at(comma_pos);\n let data = &data[1..]; // Skip comma\n\n // Extract media type (image/jpeg;base64 -> image/jpeg)\n let media_type = media_type.split(';').next().unwrap_or(\"image/jpeg\");\n\n content_parts.push(AIContentPart::Image {\n source: AIImageSource::Base64 {\n media_type: media_type.to_string(),\n data: data.to_string(),\n },\n });\n }\n }\n }\n }\n }\n AIMessage {\n role: msg.role,\n content: AIMessageContent::Multimodal(content_parts),\n }\n }\n }\n }\n}\n\nimpl From for OpenAIMessage {\n fn from(msg: AIMessage) -> Self {\n match msg.content {\n AIMessageContent::Text(text) => OpenAIMessage {\n role: msg.role,\n content: OpenAIMessageContent::Text(text),\n },\n AIMessageContent::Multimodal(parts) => {\n let mut openai_parts = Vec::new();\n\n for part in parts {\n match part {\n AIContentPart::Text { text } => {\n openai_parts.push(OpenAIContentPart::Text { text });\n }\n AIContentPart::Image { source } => {\n if let AIImageSource::Base64 { media_type, data } = source {\n // Create data URI: data:image/jpeg;base64,\n let url = format!(\"data:{};base64,{}\", media_type, data);\n openai_parts.push(OpenAIContentPart::ImageUrl {\n image_url: OpenAIImageUrl { url },\n });\n }\n }\n }\n }\n\n OpenAIMessage {\n role: msg.role,\n content: OpenAIMessageContent::Array(openai_parts),\n }\n }\n }\n }\n}\n\nimpl From for AIMessage {\n fn from(msg: OllamaMessage) -> Self {\n AIMessage::text(msg.role, msg.content)\n }\n}\n\nimpl From for OllamaMessage {\n fn from(msg: AIMessage) -> Self {\n match msg.content {\n AIMessageContent::Text(text) => OllamaMessage {\n role: msg.role,\n content: text,\n images: None,\n },\n AIMessageContent::Multimodal(parts) => {\n let mut text_parts = Vec::new();\n let mut image_data = Vec::new();\n\n for part in parts {\n match part {\n AIContentPart::Text { text } => text_parts.push(text),\n AIContentPart::Image { source } => {\n // Extract base64 image data\n if let AIImageSource::Base64 { data, .. } = source {\n image_data.push(data);\n }\n }\n }\n }\n\n OllamaMessage {\n role: msg.role,\n content: text_parts.join(\"\\n\"),\n images: if image_data.is_empty() {\n None\n } else {\n Some(image_data)\n },\n }\n }\n }\n }\n}\n\nimpl AIProvider {\n /// Get default models for provider\n pub fn default_models(&self) -> Vec {\n match self {\n AIProvider::Claude => vec![\n \"claude-3-5-sonnet-20241022\".to_string(),\n \"claude-3-opus-20240229\".to_string(),\n \"claude-3-sonnet-20240229\".to_string(),\n \"claude-3-haiku-20240307\".to_string(),\n ],\n AIProvider::OpenAI => vec![\n \"gpt-4-turbo-preview\".to_string(),\n \"gpt-4\".to_string(),\n \"gpt-3.5-turbo\".to_string(),\n ],\n AIProvider::DeepSeek => vec![\n \"deepseek-chat\".to_string(),\n \"deepseek-coder\".to_string(),\n \"deepseek-vl\".to_string(), // Vision model for image analysis\n ],\n AIProvider::Ollama => vec![\n \"llama3.2\".to_string(),\n \"llama3\".to_string(),\n \"codellama\".to_string(),\n \"mistral\".to_string(),\n ],\n }\n }\n\n /// Get default API endpoint\n pub fn default_endpoint(&self) -> String {\n match self {\n AIProvider::Claude => \"https://api.anthropic.com/v1/messages\".to_string(),\n AIProvider::OpenAI => \"https://api.openai.com/v1/chat/completions\".to_string(),\n AIProvider::DeepSeek => \"https://api.deepseek.com/v1/chat/completions\".to_string(),\n AIProvider::Ollama => \"http://localhost:11434/api/chat\".to_string(),\n }\n }\n\n /// Определить провайдера по названию модели\n pub fn from_model_name(model: &str) -> Option {\n // Claude модели\n if model.starts_with(\"claude-\") {\n return Some(AIProvider::Claude);\n }\n\n // OpenAI модели\n if model.starts_with(\"gpt-\") || model.starts_with(\"o1-\") || model.starts_with(\"text-\") {\n return Some(AIProvider::OpenAI);\n }\n\n // DeepSeek модели\n if model.starts_with(\"deepseek-\") {\n return Some(AIProvider::DeepSeek);\n }\n\n // Ollama/локальные модели\n if model.starts_with(\"llama\")\n || model.starts_with(\"mistral\")\n || model.starts_with(\"codellama\")\n || model.starts_with(\"phi\")\n || model.starts_with(\"gemma\")\n || model.starts_with(\"qwen\")\n || model.starts_with(\"vicuna\")\n || model.starts_with(\"wizard\")\n {\n return Some(AIProvider::Ollama);\n }\n\n None\n }\n}\n"}, "436702897a8f8bc3a1adbaaba0c589cdbd1591f8": {"t": 1785756933.2888412, "n": 18186, "h": "6910430175373663", "c": "use serde::{Deserialize, Serialize};\nuse specta::Type;\n\n// ============================================================================\n// UNIFIED AI PROVIDER TYPES\n// ============================================================================\n\n/// AI Provider Type\n#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq, Hash)]\n#[serde(rename_all = \"lowercase\")]\npub enum AIProvider {\n Claude,\n OpenAI,\n DeepSeek,\n Ollama,\n}\n\n/// AI Tool Definition (Claude Tool Use / OpenAI Function Calling)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct AITool {\n /// Tool name\n pub name: String,\n\n /// Tool description\n pub description: String,\n\n /// Input schema (JSON Schema)\n pub input_schema: serde_json::Value,\n}\n\n/// AI Tool Call (when AI decides to use a tool)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIToolCall {\n /// Tool call ID\n pub id: String,\n\n /// Tool name\n pub name: String,\n\n /// Tool input (JSON)\n pub input: serde_json::Value,\n}\n\n/// Tool Choice Strategy\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum ToolChoice {\n /// AI decides whether to use tools\n Auto,\n\n /// Force AI to use a specific tool\n Required,\n\n /// Specific tool to use\n Tool { name: String },\n\n /// Don't use tools\n None,\n}\n\n/// Unified AI Request (works with all providers)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct UnifiedAIRequest {\n pub provider: AIProvider,\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n pub system: Option,\n /// 🆕 Tools для Function Calling / Claude Tool Use\n pub tools: Option>,\n /// 🆕 Tool choice strategy\n pub tool_choice: Option,\n}\n\n/// AI Message Content (supports text and images)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(untagged)]\npub enum AIMessageContent {\n /// Simple text message\n Text(String),\n\n /// Multimodal message with text and/or images\n Multimodal(Vec),\n}\n\n/// Content part (text or image)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AIContentPart {\n /// Text content\n Text { text: String },\n\n /// Image content (base64 or URL)\n Image {\n #[serde(flatten)]\n source: AIImageSource,\n },\n}\n\n/// Image source (base64 or URL)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AIImageSource {\n /// Base64 encoded image\n Base64 {\n #[serde(rename = \"media_type\")]\n media_type: String, // e.g., \"image/jpeg\", \"image/png\"\n data: String, // base64 encoded\n },\n\n /// Image URL\n Url { url: String },\n}\n\n/// Unified AI Message (supports text and vision)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct AIMessage {\n pub role: String,\n pub content: AIMessageContent,\n}\n\nimpl AIMessage {\n /// Create a simple text message\n pub fn text(role: impl Into, text: impl Into) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Text(text.into()),\n }\n }\n\n /// Create a message with image (base64)\n pub fn with_image_base64(\n role: impl Into,\n text: impl Into,\n image_data: String,\n media_type: impl Into,\n ) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Multimodal(vec![\n AIContentPart::Text { text: text.into() },\n AIContentPart::Image {\n source: AIImageSource::Base64 {\n media_type: media_type.into(),\n data: image_data,\n },\n },\n ]),\n }\n }\n\n /// Create a message with image URL\n pub fn with_image_url(\n role: impl Into,\n text: impl Into,\n image_url: impl Into,\n ) -> Self {\n Self {\n role: role.into(),\n content: AIMessageContent::Multimodal(vec![\n AIContentPart::Text { text: text.into() },\n AIContentPart::Image {\n source: AIImageSource::Url {\n url: image_url.into(),\n },\n },\n ]),\n }\n }\n\n /// Get text content from message\n pub fn get_text(&self) -> String {\n match &self.content {\n AIMessageContent::Text(text) => text.clone(),\n AIMessageContent::Multimodal(parts) => parts\n .iter()\n .filter_map(|part| {\n if let AIContentPart::Text { text } = part {\n Some(text.as_str())\n } else {\n None\n }\n })\n .collect::>()\n .join(\"\\n\"),\n }\n }\n}\n\n/// Unified AI Response\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct UnifiedAIResponse {\n pub id: String,\n pub provider: AIProvider,\n pub model: String,\n pub content: String,\n pub usage: Option,\n pub finish_reason: Option,\n /// 🆕 Tool calls если AI вызвал инструменты\n pub tool_calls: Option>,\n}\n\n/// Token Usage (unified across providers)\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct TokenUsage {\n pub input_tokens: u32,\n pub output_tokens: u32,\n pub total_tokens: u32,\n}\n\n// ============================================================================\n// CLAUDE-SPECIFIC TYPES (legacy compatibility)\n// ============================================================================\n\n/// Запрос к Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeApiRequest {\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n pub system: Option,\n}\n\n/// Сообщение для Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeMessage {\n pub role: String,\n pub content: String,\n}\n\n/// Ответ от Claude API\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeApiResponse {\n pub id: String,\n pub model: String,\n pub content: Vec,\n pub usage: Option,\n}\n\n/// Контент от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeContent {\n pub r#type: String,\n pub text: Option,\n}\n\n/// Использование токенов\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeUsage {\n pub input_tokens: u32,\n pub output_tokens: u32,\n}\n\n/// Потоковый ответ от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeStreamChunk {\n pub r#type: String,\n pub delta: Option,\n pub message: Option,\n}\n\n/// Дельта изменения в потоке\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeDelta {\n pub r#type: String,\n pub text: Option,\n}\n\n// ============================================================================\n// OPENAI-SPECIFIC TYPES\n// ============================================================================\n\n/// OpenAI API Request\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIRequest {\n pub model: String,\n pub messages: Vec,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream: Option,\n}\n\n/// OpenAI Message Content (supports text and vision)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(untagged)]\npub enum OpenAIMessageContent {\n /// Simple text content (backward compatible)\n Text(String),\n /// Multimodal content array (text + images for vision models)\n Array(Vec),\n}\n\n/// OpenAI Content Part (for multimodal messages)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum OpenAIContentPart {\n /// Text content part\n Text { text: String },\n /// Image URL content part (for vision models like GPT-4o, GPT-4 Turbo, DeepSeek-VL)\n ImageUrl { image_url: OpenAIImageUrl },\n}\n\n/// OpenAI Image URL (supports base64 data URIs)\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIImageUrl {\n /// Image URL (can be data:image/jpeg;base64,... or https://...)\n pub url: String,\n}\n\n/// OpenAI Message\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIMessage {\n pub role: String,\n pub content: OpenAIMessageContent,\n}\n\n/// OpenAI Response\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIResponse {\n pub id: String,\n pub object: String,\n pub created: u64,\n pub model: String,\n pub choices: Vec,\n pub usage: Option,\n}\n\n/// OpenAI Choice\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIChoice {\n pub index: u32,\n pub message: OpenAIMessage,\n pub finish_reason: Option,\n}\n\n/// OpenAI Usage\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OpenAIUsage {\n pub prompt_tokens: u32,\n pub completion_tokens: u32,\n pub total_tokens: u32,\n}\n\n// ============================================================================\n// DEEPSEEK-SPECIFIC TYPES\n// ============================================================================\n\n/// DeepSeek uses OpenAI-compatible API\npub type DeepSeekRequest = OpenAIRequest;\npub type DeepSeekResponse = OpenAIResponse;\npub type DeepSeekMessage = OpenAIMessage;\n\n// ============================================================================\n// OLLAMA-SPECIFIC TYPES\n// ============================================================================\n\n/// Ollama API Request\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaRequest {\n pub model: String,\n pub messages: Vec,\n pub stream: Option,\n pub options: Option,\n}\n\n/// Ollama Message\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaMessage {\n pub role: String,\n pub content: String,\n /// Base64 encoded images for vision models (llama3.2-vision, moondream2, llava)\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub images: Option>,\n}\n\n/// Ollama Options\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaOptions {\n pub temperature: Option,\n pub num_predict: Option, // equivalent to max_tokens\n}\n\n/// Ollama Response\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaResponse {\n pub model: String,\n pub created_at: String,\n pub message: OllamaMessage,\n pub done: bool,\n}\n\n// ============================================================================\n// VALIDATION TYPES\n// ============================================================================\n\n/// Запрос на валидацию API ключа\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct ValidateApiKeyRequest {\n pub api_key: String,\n pub provider: AIProvider,\n}\n\n/// Результат валидации\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct ValidateApiKeyResponse {\n pub valid: bool,\n pub message: String,\n pub models: Option>,\n}\n\n// ============================================================================\n// PROVIDER CONFIG\n// ============================================================================\n\n/// AI Provider Configuration\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderConfig {\n pub provider: AIProvider,\n pub api_key: Option,\n pub base_url: Option,\n pub default_model: Option,\n pub timeout: Option, // seconds\n}\n\n/// Provider Status\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\n#[serde(rename_all = \"camelCase\")]\npub struct ProviderStatus {\n pub provider: AIProvider,\n pub available: bool,\n pub models: Vec,\n pub error: Option,\n}\n\n// ============================================================================\n// CONVERSION IMPLEMENTATIONS\n// ============================================================================\n\nimpl From for AIMessage {\n fn from(msg: ClaudeMessage) -> Self {\n AIMessage {\n role: msg.role,\n content: AIMessageContent::Text(msg.content),\n }\n }\n}\n\nimpl From for ClaudeMessage {\n fn from(msg: AIMessage) -> Self {\n let text = msg.get_text();\n ClaudeMessage {\n role: msg.role,\n content: text,\n }\n }\n}\n\nimpl From for AIMessage {\n fn from(msg: OpenAIMessage) -> Self {\n match msg.content {\n OpenAIMessageContent::Text(text) => AIMessage::text(msg.role, text),\n OpenAIMessageContent::Array(parts) => {\n let mut content_parts = Vec::new();\n for part in parts {\n match part {\n OpenAIContentPart::Text { text } => {\n content_parts.push(AIContentPart::Text { text });\n }\n OpenAIContentPart::ImageUrl { image_url } => {\n // Extract base64 from data URI (data:image/jpeg;base64,...)\n if let Some(base64_data) = image_url.url.strip_prefix(\"data:\") {\n if let Some(comma_pos) = base64_data.find(',') {\n let (media_type, data) = base64_data.split_at(comma_pos);\n let data = &data[1..]; // Skip comma\n\n // Extract media type (image/jpeg;base64 -> image/jpeg)\n let media_type = media_type.split(';').next().unwrap_or(\"image/jpeg\");\n\n content_parts.push(AIContentPart::Image {\n source: AIImageSource::Base64 {\n media_type: media_type.to_string(),\n data: data.to_string(),\n },\n });\n }\n }\n }\n }\n }\n AIMessage {\n role: msg.role,\n content: AIMessageContent::Multimodal(content_parts),\n }\n }\n }\n }\n}\n\nimpl From for OpenAIMessage {\n fn from(msg: AIMessage) -> Self {\n match msg.content {\n AIMessageContent::Text(text) => OpenAIMessage {\n role: msg.role,\n content: OpenAIMessageContent::Text(text),\n },\n AIMessageContent::Multimodal(parts) => {\n let mut openai_parts = Vec::new();\n\n for part in parts {\n match part {\n AIContentPart::Text { text } => {\n openai_parts.push(OpenAIContentPart::Text { text });\n }\n AIContentPart::Image { source } => {\n if let AIImageSource::Base64 { media_type, data } = source {\n // Create data URI: data:image/jpeg;base64,\n let url = format!(\"data:{};base64,{}\", media_type, data);\n openai_parts.push(OpenAIContentPart::ImageUrl {\n image_url: OpenAIImageUrl { url },\n });\n }\n }\n }\n }\n\n OpenAIMessage {\n role: msg.role,\n content: OpenAIMessageContent::Array(openai_parts),\n }\n }\n }\n }\n}\n\nimpl From for AIMessage {\n fn from(msg: OllamaMessage) -> Self {\n AIMessage::text(msg.role, msg.content)\n }\n}\n\nimpl From for OllamaMessage {\n fn from(msg: AIMessage) -> Self {\n match msg.content {\n AIMessageContent::Text(text) => OllamaMessage {\n role: msg.role,\n content: text,\n images: None,\n },\n AIMessageContent::Multimodal(parts) => {\n let mut text_parts = Vec::new();\n let mut image_data = Vec::new();\n\n for part in parts {\n match part {\n AIContentPart::Text { text } => text_parts.push(text),\n AIContentPart::Image { source } => {\n // Extract base64 image data\n if let AIImageSource::Base64 { data, .. } = source {\n image_data.push(data);\n }\n }\n }\n }\n\n OllamaMessage {\n role: msg.role,\n content: text_parts.join(\"\\n\"),\n images: if image_data.is_empty() {\n None\n } else {\n Some(image_data)\n },\n }\n }\n }\n }\n}\n\nimpl AIProvider {\n /// Get default models for provider\n pub fn default_models(&self) -> Vec {\n match self {\n AIProvider::Claude => vec![\n \"claude-3-5-sonnet-20241022\".to_string(),\n \"claude-3-opus-20240229\".to_string(),\n \"claude-3-sonnet-20240229\".to_string(),\n \"claude-3-haiku-20240307\".to_string(),\n ],\n AIProvider::OpenAI => vec![\n \"gpt-4-turbo-preview\".to_string(),\n \"gpt-4\".to_string(),\n \"gpt-3.5-turbo\".to_string(),\n ],\n AIProvider::DeepSeek => vec![\n \"deepseek-chat\".to_string(),\n \"deepseek-coder\".to_string(),\n \"deepseek-vl\".to_string(), // Vision model for image analysis\n ],\n AIProvider::Ollama => vec![\n \"llama3.2\".to_string(),\n \"llama3\".to_string(),\n \"codellama\".to_string(),\n \"mistral\".to_string(),\n ],\n }\n }\n\n /// Get default API endpoint\n pub fn default_endpoint(&self) -> String {\n match self {\n AIProvider::Claude => \"https://api.anthropic.com/v1/messages\".to_string(),\n AIProvider::OpenAI => \"https://api.openai.com/v1/chat/completions\".to_string(),\n AIProvider::DeepSeek => \"https://api.deepseek.com/v1/chat/completions\".to_string(),\n AIProvider::Ollama => \"http://localhost:11434/api/chat\".to_string(),\n }\n }\n\n /// Определить провайдера по названию модели\n pub fn from_model_name(model: &str) -> Option {\n // Claude модели\n if model.starts_with(\"claude-\") {\n return Some(AIProvider::Claude);\n }\n\n // OpenAI модели\n if model.starts_with(\"gpt-\") || model.starts_with(\"o1-\") || model.starts_with(\"text-\") {\n return Some(AIProvider::OpenAI);\n }\n\n // DeepSeek модели\n if model.starts_with(\"deepseek-\") {\n return Some(AIProvider::DeepSeek);\n }\n\n // Ollama/локальные модели\n if model.starts_with(\"llama\")\n || model.starts_with(\"mistral\")\n || model.starts_with(\"codellama\")\n || model.starts_with(\"phi\")\n || model.starts_with(\"gemma\")\n || model.starts_with(\"qwen\")\n || model.starts_with(\"vicuna\")\n || model.starts_with(\"wizard\")\n {\n return Some(AIProvider::Ollama);\n }\n\n None\n }\n}\n"}}, "docs/01_project_docs/choosing-vision-provider.md": {"9253fded22485cb087ef100842c6be6a8989dfc4": {"t": 1785747198.1559281, "n": 6293, "h": "3f243389742b466c", "c": "# Как выбрать провайдера для Vision Analysis\n\n## Быстрый выбор\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен абсолютно бесплатный вариант? │\n│ ✅ Да → Ollama (moondream2 или llava) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Важна ли конфиденциальность (данные не должны уходить │\n│ из компьютера)? │\n│ ✅ Да → Ollama (100% локально) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен лучший баланс цена/качество? │\n│ ✅ Да → DeepSeek Vision (~$0.01/видео) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужно максимальное качество анализа? │\n│ ✅ Да → GPT-4o или Claude 4.5 Sonnet │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Сравнительная таблица\n\n| Критерий | Ollama | DeepSeek | GPT-4o | Claude |\n|----------|--------|----------|--------|--------|\n| **Стоимость** | $0 | ~$0.01 | ~$0.08 | ~$0.12 |\n| **Качество** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |\n| **Скорость** | ⚡⚡⚡ | ⚡⚡ | ⚡⚡ | ⚡⚡ |\n| **Privacy** | ✅ 100% | ❌ Облако | ❌ Облако | ❌ Облако |\n| **Требует GPU** | Желательно | ❌ Нет | ❌ Нет | ❌ Нет |\n| **API ключ** | ❌ Не нужен | ✅ Нужен | ✅ Нужен | ✅ Нужен |\n\n## Рекомендации по Use Cases\n\n### Для личных проектов и обучения\n**→ Ollama (moondream2)**\n- Бесплатно\n- Легко начать\n- Достаточно для большинства задач\n\n### Для малого бизнеса / стартапов\n**→ DeepSeek Vision**\n- Очень низкая стоимость (~$1 за 100 видео)\n- Хорошее качество\n- Масштабируется\n\n### Для профессионального use\n**→ GPT-4o**\n- Надёжность и стабильность API\n- Высокое качество\n- Доступная цена ($8 за 100 видео)\n\n### Для критичных проектов\n**→ Claude 4.5 Sonnet**\n- Лучшее понимание контекста\n- Продвинутый reasoning\n- Премиум качество\n\n## Подробное сравнение\n\n### Ollama ⭐ Рекомендуется для начала\n\n**Когда использовать:**\n- Разработка и тестирование\n- Ограниченный бюджет\n- Важна конфиденциальность\n- Есть GPU (8GB+ VRAM для llava)\n\n**Когда НЕ использовать:**\n- Нужно максимальное качество\n- Нет GPU (будет медленно)\n- Критична скорость (облако быстрее)\n\n**Лучшая модель**: `llava` (если есть GPU) или `moondream2` (компактная)\n\n### DeepSeek Vision 💰 Лучший баланс\n\n**Когда использовать:**\n- Production с большим количеством видео\n- Нужен баланс цена/качество\n- Бюджет ограничен но есть\n\n**Когда НЕ использовать:**\n- Нужно абсолютно бесплатно\n- Критична максимальная точность\n\n**Цена**: $0.14 за 1M input tokens + $0.28 за 1M output tokens\n**Практика**: ~$0.01 за 10-минутное видео (10 кадров)\n\n### OpenAI GPT-4o 🎯 Высокое качество\n\n**Когда использовать:**\n- Production проекты\n- Важна точность и детальность\n- Нужна стабильность API\n\n**Когда НЕ использовать:**\n- Очень большие объёмы (дорого)\n- Достаточно более простых моделей\n\n**Цена**: $2.50 за 1M input tokens + $10.00 за 1M output tokens\n**Практика**: ~$0.08 за 10-минутное видео (5 кадров)\n\n### Claude 4.5 Sonnet 👑 Премиум\n\n**Когда использовать:**\n- Нужно лучшее качество\n- Сложные задачи анализа\n- Критична точность\n\n**Когда НЕ использовать:**\n- Ограниченный бюджет\n- Большие объёмы видео\n\n**Цена**: $3.00 за 1M input tokens + $15.00 за 1M output tokens\n**Практика**: ~$0.12 за 10-минутное видео (5 кадров)\n\n## Настройка в приложении\n\n### 1. Ollama (локальный)\n\n```bash\n# Установить Ollama\nbrew install ollama # macOS\n\n# Запустить сервис\nollama serve\n\n# Скачать модель\nollama pull moondream2\n```\n\nВ приложении:\n- Settings → AI Analysis → Provider: **Ollama**\n- Model: **moondream2** (или llava)\n- API Key: не требуется\n\n### 2. DeepSeek\n\n1. Зарегистрироваться: https://platform.deepseek.com\n2. API Keys → Create new key\n3. Пополнить баланс: минимум $5\n\nВ приложении:\n- Settings → API Keys → DeepSeek API Key: `sk-...`\n- Settings → AI Analysis → Provider: **DeepSeek**\n- Model: **deepseek-vl**\n\n### 3. OpenAI\n\n1. Зарегистрироваться: https://platform.openai.com\n2. API Keys → Create new secret key\n3. Добавить payment method\n\nВ приложении:\n- Settings → API Keys → OpenAI API Key: `sk-proj-...`\n- Settings → AI Analysis → Provider: **OpenAI**\n- Model: **gpt-4o**\n\n### 4. Claude\n\n1. Зарегистрироваться: https://console.anthropic.com\n2. API Keys → Create key\n3. Пополнить credits\n\nВ приложении:\n- Settings → API Keys → Claude API Key: `sk-ant-...`\n- Settings → AI Analysis → Provider: **Claude**\n- Model: **claude-4-5-sonnet**\n\n## Оптимизация стоимости\n\n### Уменьшить количество кадров\n```typescript\n// Вместо 10 кадров используйте 5\nvlm_num_frames: 5 // Снизит стоимость в 2 раза\n```\n\n### Использовать Fast mode для preview\n```typescript\nperformance_mode: 'Fast' // 3 кадра вместо 5-10\n```\n\n### Комбинировать провайдеры\n```typescript\n// Разработка: Ollama (бесплатно)\n// Production: DeepSeek (дёшево)\n// Критичные проекты: GPT-4o\n```\n\n## Мониторинг расходов\n\n### DeepSeek\nDashboard: https://platform.deepseek.com/usage\n\n### OpenAI\nDashboard: https://platform.openai.com/usage\n\n### Claude\nDashboard: https://console.anthropic.com/settings/usage\n\n## Часто задаваемые вопросы\n\n**Q: Можно ли использовать несколько провайдеров?**\nA: Да, можно переключаться в Settings → AI Analysis\n\n**Q: Какой провайдер самый быстрый?**\nA: Ollama (локально) при наличии GPU, иначе GPT-4o\n\n**Q: Можно ли использовать бесплатно для коммерческих проектов?**\nA: Да, Ollama полностью бесплатен и подходит для commercial use\n\n**Q: Что делать если закончились credits?**\nA: Пополнить баланс на платформе провайдера или переключиться на Ollama\n\n**Q: Можно ли использовать свой сервер Ollama?**\nA: Да, указать custom endpoint в настройках (будет добавлено)\n\n---\n\n*Создано: 2025-07-31*\n*Обновлено: 2025-11-29*\n"}, "546b3af805a0c99ea145bf02299edbfcc499b75b": {"t": 1785756930.435064, "n": 6293, "h": "3f243389742b466c", "c": "# Как выбрать провайдера для Vision Analysis\n\n## Быстрый выбор\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен абсолютно бесплатный вариант? │\n│ ✅ Да → Ollama (moondream2 или llava) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Важна ли конфиденциальность (данные не должны уходить │\n│ из компьютера)? │\n│ ✅ Да → Ollama (100% локально) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен лучший баланс цена/качество? │\n│ ✅ Да → DeepSeek Vision (~$0.01/видео) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужно максимальное качество анализа? │\n│ ✅ Да → GPT-4o или Claude 4.5 Sonnet │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Сравнительная таблица\n\n| Критерий | Ollama | DeepSeek | GPT-4o | Claude |\n|----------|--------|----------|--------|--------|\n| **Стоимость** | $0 | ~$0.01 | ~$0.08 | ~$0.12 |\n| **Качество** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |\n| **Скорость** | ⚡⚡⚡ | ⚡⚡ | ⚡⚡ | ⚡⚡ |\n| **Privacy** | ✅ 100% | ❌ Облако | ❌ Облако | ❌ Облако |\n| **Требует GPU** | Желательно | ❌ Нет | ❌ Нет | ❌ Нет |\n| **API ключ** | ❌ Не нужен | ✅ Нужен | ✅ Нужен | ✅ Нужен |\n\n## Рекомендации по Use Cases\n\n### Для личных проектов и обучения\n**→ Ollama (moondream2)**\n- Бесплатно\n- Легко начать\n- Достаточно для большинства задач\n\n### Для малого бизнеса / стартапов\n**→ DeepSeek Vision**\n- Очень низкая стоимость (~$1 за 100 видео)\n- Хорошее качество\n- Масштабируется\n\n### Для профессионального use\n**→ GPT-4o**\n- Надёжность и стабильность API\n- Высокое качество\n- Доступная цена ($8 за 100 видео)\n\n### Для критичных проектов\n**→ Claude 4.5 Sonnet**\n- Лучшее понимание контекста\n- Продвинутый reasoning\n- Премиум качество\n\n## Подробное сравнение\n\n### Ollama ⭐ Рекомендуется для начала\n\n**Когда использовать:**\n- Разработка и тестирование\n- Ограниченный бюджет\n- Важна конфиденциальность\n- Есть GPU (8GB+ VRAM для llava)\n\n**Когда НЕ использовать:**\n- Нужно максимальное качество\n- Нет GPU (будет медленно)\n- Критична скорость (облако быстрее)\n\n**Лучшая модель**: `llava` (если есть GPU) или `moondream2` (компактная)\n\n### DeepSeek Vision 💰 Лучший баланс\n\n**Когда использовать:**\n- Production с большим количеством видео\n- Нужен баланс цена/качество\n- Бюджет ограничен но есть\n\n**Когда НЕ использовать:**\n- Нужно абсолютно бесплатно\n- Критична максимальная точность\n\n**Цена**: $0.14 за 1M input tokens + $0.28 за 1M output tokens\n**Практика**: ~$0.01 за 10-минутное видео (10 кадров)\n\n### OpenAI GPT-4o 🎯 Высокое качество\n\n**Когда использовать:**\n- Production проекты\n- Важна точность и детальность\n- Нужна стабильность API\n\n**Когда НЕ использовать:**\n- Очень большие объёмы (дорого)\n- Достаточно более простых моделей\n\n**Цена**: $2.50 за 1M input tokens + $10.00 за 1M output tokens\n**Практика**: ~$0.08 за 10-минутное видео (5 кадров)\n\n### Claude 4.5 Sonnet 👑 Премиум\n\n**Когда использовать:**\n- Нужно лучшее качество\n- Сложные задачи анализа\n- Критична точность\n\n**Когда НЕ использовать:**\n- Ограниченный бюджет\n- Большие объёмы видео\n\n**Цена**: $3.00 за 1M input tokens + $15.00 за 1M output tokens\n**Практика**: ~$0.12 за 10-минутное видео (5 кадров)\n\n## Настройка в приложении\n\n### 1. Ollama (локальный)\n\n```bash\n# Установить Ollama\nbrew install ollama # macOS\n\n# Запустить сервис\nollama serve\n\n# Скачать модель\nollama pull moondream2\n```\n\nВ приложении:\n- Settings → AI Analysis → Provider: **Ollama**\n- Model: **moondream2** (или llava)\n- API Key: не требуется\n\n### 2. DeepSeek\n\n1. Зарегистрироваться: https://platform.deepseek.com\n2. API Keys → Create new key\n3. Пополнить баланс: минимум $5\n\nВ приложении:\n- Settings → API Keys → DeepSeek API Key: `sk-...`\n- Settings → AI Analysis → Provider: **DeepSeek**\n- Model: **deepseek-vl**\n\n### 3. OpenAI\n\n1. Зарегистрироваться: https://platform.openai.com\n2. API Keys → Create new secret key\n3. Добавить payment method\n\nВ приложении:\n- Settings → API Keys → OpenAI API Key: `sk-proj-...`\n- Settings → AI Analysis → Provider: **OpenAI**\n- Model: **gpt-4o**\n\n### 4. Claude\n\n1. Зарегистрироваться: https://console.anthropic.com\n2. API Keys → Create key\n3. Пополнить credits\n\nВ приложении:\n- Settings → API Keys → Claude API Key: `sk-ant-...`\n- Settings → AI Analysis → Provider: **Claude**\n- Model: **claude-4-5-sonnet**\n\n## Оптимизация стоимости\n\n### Уменьшить количество кадров\n```typescript\n// Вместо 10 кадров используйте 5\nvlm_num_frames: 5 // Снизит стоимость в 2 раза\n```\n\n### Использовать Fast mode для preview\n```typescript\nperformance_mode: 'Fast' // 3 кадра вместо 5-10\n```\n\n### Комбинировать провайдеры\n```typescript\n// Разработка: Ollama (бесплатно)\n// Production: DeepSeek (дёшево)\n// Критичные проекты: GPT-4o\n```\n\n## Мониторинг расходов\n\n### DeepSeek\nDashboard: https://platform.deepseek.com/usage\n\n### OpenAI\nDashboard: https://platform.openai.com/usage\n\n### Claude\nDashboard: https://console.anthropic.com/settings/usage\n\n## Часто задаваемые вопросы\n\n**Q: Можно ли использовать несколько провайдеров?**\nA: Да, можно переключаться в Settings → AI Analysis\n\n**Q: Какой провайдер самый быстрый?**\nA: Ollama (локально) при наличии GPU, иначе GPT-4o\n\n**Q: Можно ли использовать бесплатно для коммерческих проектов?**\nA: Да, Ollama полностью бесплатен и подходит для commercial use\n\n**Q: Что делать если закончились credits?**\nA: Пополнить баланс на платформе провайдера или переключиться на Ollama\n\n**Q: Можно ли использовать свой сервер Ollama?**\nA: Да, указать custom endpoint в настройках (будет добавлено)\n\n---\n\n*Создано: 2025-07-31*\n*Обновлено: 2025-11-29*\n"}, "6113c570f70139ce26b33836cce6a61add5d65be": {"t": 1785756931.8051033, "n": 6297, "h": "0f24449d20df1e9a", "c": "# Как выбрать провайдера для Vision Analysis\n\n## Быстрый выбор\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен абсолютно бесплатный вариант? │\n│ ✅ Да → Ollama (moondream2 или llava) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Важна ли конфиденциальность (данные не должны уходить │\n│ из компьютера)? │\n│ ✅ Да → Ollama (100% локально) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен лучший баланс цена/качество? │\n│ ✅ Да → DeepSeek Vision (~$0.01/видео) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужно максимальное качество анализа? │\n│ ✅ Да → GPT-4o или Claude 4.5 Sonnet │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Сравнительная таблица\n\n| Критерий | Ollama | DeepSeek | GPT-4o | Claude |\n|----------|--------|----------|--------|--------|\n| **Стоимость** | $0 | ~$0.01 | ~$0.08 | ~$0.12 |\n| **Качество** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |\n| **Скорость** | ⚡⚡⚡ | ⚡⚡ | ⚡⚡ | ⚡⚡ |\n| **Privacy** | ✅ 100% | ❌ Облако | ❌ Облако | ❌ Облако |\n| **Требует GPU** | Желательно | ❌ Нет | ❌ Нет | ❌ Нет |\n| **API ключ** | ❌ Не нужен | ✅ Нужен | ✅ Нужен | ✅ Нужен |\n\n## Рекомендации по Use Cases\n\n### Для личных проектов и обучения\n**→ Ollama (moondream2)**\n- Бесплатно\n- Легко начать\n- Достаточно для большинства задач\n\n### Для малого бизнеса / стартапов\n**→ DeepSeek Vision**\n- Очень низкая стоимость (~$1 за 100 видео)\n- Хорошее качество\n- Масштабируется\n\n### Для профессионального use\n**→ GPT-4o**\n- Надёжность и стабильность API\n- Высокое качество\n- Доступная цена ($8 за 100 видео)\n\n### Для критичных проектов\n**→ Claude 4.5 Sonnet**\n- Лучшее понимание контекста\n- Продвинутый reasoning\n- Премиум качество\n\n## Подробное сравнение\n\n### Ollama ⭐ Рекомендуется для начала\n\n**Когда использовать:**\n- Разработка и тестирование\n- Ограниченный бюджет\n- Важна конфиденциальность\n- Есть GPU (8GB+ VRAM для llava)\n\n**Когда НЕ использовать:**\n- Нужно максимальное качество\n- Нет GPU (будет медленно)\n- Критична скорость (облако быстрее)\n\n**Лучшая модель**: `llava` (если есть GPU) или `moondream2` (компактная)\n\n### DeepSeek Vision 💰 Лучший баланс\n\n**Когда использовать:**\n- Production с большим количеством видео\n- Нужен баланс цена/качество\n- Бюджет ограничен но есть\n\n**Когда НЕ использовать:**\n- Нужно абсолютно бесплатно\n- Критична максимальная точность\n\n**Цена**: $0.14 за 1M input tokens + $0.28 за 1M output tokens\n**Практика**: ~$0.01 за 10-минутное видео (10 кадров)\n\n### OpenAI GPT-4o 🎯 Высокое качество\n\n**Когда использовать:**\n- Production проекты\n- Важна точность и детальность\n- Нужна стабильность API\n\n**Когда НЕ использовать:**\n- Очень большие объёмы (дорого)\n- Достаточно более простых моделей\n\n**Цена**: $2.50 за 1M input tokens + $10.00 за 1M output tokens\n**Практика**: ~$0.08 за 10-минутное видео (5 кадров)\n\n### Claude 4.5 Sonnet 👑 Премиум\n\n**Когда использовать:**\n- Нужно лучшее качество\n- Сложные задачи анализа\n- Критична точность\n\n**Когда НЕ использовать:**\n- Ограниченный бюджет\n- Большие объёмы видео\n\n**Цена**: $3.00 за 1M input tokens + $15.00 за 1M output tokens\n**Практика**: ~$0.12 за 10-минутное видео (5 кадров)\n\n## Настройка в приложении\n\n### 1. Ollama (локальный)\n\n```bash\n# Установить Ollama\nbrew install ollama # macOS\n\n# Запустить сервис\nollama serve\n\n# Скачать модель\nollama pull moondream2\n```\n\nВ приложении:\n- Settings → AI Analysis → Provider: **Ollama**\n- Model: **moondream2** (или llava)\n- API Key: не требуется\n\n### 2. DeepSeek\n\n1. Зарегистрироваться: https://platform.deepseek.com\n2. API Keys → Create new key\n3. Пополнить баланс: минимум $5\n\nВ приложении:\n- Settings → API Keys → DeepSeek API Key: `sk-...`\n- Settings → AI Analysis → Provider: **DeepSeek**\n- Model: **deepseek-vl**\n\n### 3. OpenAI\n\n1. Зарегистрироваться: https://platform.openai.com\n2. API Keys → Create new secret key\n3. Добавить payment method\n\nВ приложении:\n- Settings → API Keys → OpenAI API Key: `sk-proj-...`\n- Settings → AI Analysis → Provider: **OpenAI**\n- Model: **gpt-4o**\n\n### 4. Claude\n\n1. Зарегистрироваться: https://console.anthropic.com\n2. API Keys → Create key\n3. Пополнить credits\n\nВ приложении:\n- Settings → API Keys → Claude API Key: `sk-ant-...`\n- Settings → AI Analysis → Provider: **Claude**\n- Model: **claude-4-5-sonnet**\n\n## Оптимизация стоимости\n\n### Уменьшить количество кадров\n```typescript\n// Вместо 10 кадров используйте 5\nvlm_num_frames: 5 // Снизит стоимость в 2 раза\n```\n\n### Использовать Fast mode для preview\n```typescript\nperformance_mode: 'Fast' // 3 кадра вместо 5-10\n```\n\n### Комбинировать провайдеры\n```typescript\n// Разработка: Ollama (бесплатно)\n// Production: DeepSeek (дёшево)\n// Критичные проекты: GPT-4o\n```\n\n## Мониторинг расходов\n\n### DeepSeek\nDashboard: https://platform.deepseek.com/usage\n\n### OpenAI\nDashboard: https://platform.openai.com/usage\n\n### Claude\nDashboard: https://console.anthropic.com/settings/usage\n\n## Часто задаваемые вопросы\n\n**Q: Можно ли использовать несколько провайдеров?**\nA: Да, можно переключаться в Settings → AI Analysis\n\n**Q: Какой провайдер самый быстрый?**\nA: Ollama (локально) при наличии GPU, иначе GPT-4o\n\n**Q: Можно ли использовать бесплатно для коммерческих проектов?**\nA: Да, Ollama полностью бесплатен и подходит для commercial use\n\n**Q: Что делать если закончились credits?**\nA: Пополнить баланс на платформе провайдера или переключиться на Ollama\n\n**Q: Можно ли использовать свой сервер Ollama?**\nA: Да, указать custom endpoint в настройках (будет добавлено)\n\n---\n\n*Создано: 2025-07-31*\n*Обновлено: 2025-11-29*\n"}, "741bef3a57f1ff516718deec1f2f347e53d93169": {"t": 1785756933.3470833, "n": 6245, "h": "f93e34ed5f7e80be", "c": "# Как выбрать провайдера для Vision Analysis\n\n## Быстрый выбор\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен абсолютно бесплатный вариант? │\n│ ✅ Да → Ollama (moondream2 или llava) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Важна ли конфиденциальность (данные не должны уходить │\n│ из компьютера)? │\n│ ✅ Да → Ollama (100% локально) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужен лучший баланс цена/качество? │\n│ ✅ Да → DeepSeek Vision (~$0.01/видео) │\n│ ❌ Нет → Продолжить │\n└─────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────┐\n│ Нужно максимальное качество анализа? │\n│ ✅ Да → GPT-4o или Claude 4.5 Sonnet │\n└─────────────────────────────────────────────────────────────┘\n```\n\n## Сравнительная таблица\n\n| Критерий | Ollama | DeepSeek | GPT-4o | Claude |\n|----------|--------|----------|--------|--------|\n| **Стоимость** | $0 | ~$0.01 | ~$0.08 | ~$0.12 |\n| **Качество** | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |\n| **Скорость** | ⚡⚡⚡ | ⚡⚡ | ⚡⚡ | ⚡⚡ |\n| **Privacy** | ✅ 100% | ❌ Облако | ❌ Облако | ❌ Облако |\n| **Требует GPU** | Желательно | ❌ Нет | ❌ Нет | ❌ Нет |\n| **API ключ** | ❌ Не нужен | ✅ Нужен | ✅ Нужен | ✅ Нужен |\n\n## Рекомендации по Use Cases\n\n### Для личных проектов и обучения\n**→ Ollama (moondream2)**\n- Бесплатно\n- Легко начать\n- Достаточно для большинства задач\n\n### Для малого бизнеса / стартапов\n**→ DeepSeek Vision**\n- Очень низкая стоимость (~$1 за 100 видео)\n- Хорошее качество\n- Масштабируется\n\n### Для профессионального use\n**→ GPT-4o**\n- Надёжность и стабильность API\n- Высокое качество\n- Доступная цена ($8 за 100 видео)\n\n### Для критичных проектов\n**→ Claude 4.5 Sonnet**\n- Лучшее понимание контекста\n- Продвинутый reasoning\n- Премиум качество\n\n## Подробное сравнение\n\n### Ollama ⭐ Рекомендуется для начала\n\n**Когда использовать:**\n- Разработка и тестирование\n- Ограниченный бюджет\n- Важна конфиденциальность\n- Есть GPU (8GB+ VRAM для llava)\n\n**Когда НЕ использовать:**\n- Нужно максимальное качество\n- Нет GPU (будет медленно)\n- Критична скорость (облако быстрее)\n\n**Лучшая модель**: `llava` (если есть GPU) или `moondream2` (компактная)\n\n### DeepSeek Vision 💰 Лучший баланс\n\n**Когда использовать:**\n- Production с большим количеством видео\n- Нужен баланс цена/качество\n- Бюджет ограничен но есть\n\n**Когда НЕ использовать:**\n- Нужно абсолютно бесплатно\n- Критична максимальная точность\n\n**Цена**: $0.14 за 1M input tokens + $0.28 за 1M output tokens\n**Практика**: ~$0.01 за 10-минутное видео (10 кадров)\n\n### OpenAI GPT-4o 🎯 Высокое качество\n\n**Когда использовать:**\n- Production проекты\n- Важна точность и детальность\n- Нужна стабильность API\n\n**Когда НЕ использовать:**\n- Очень большие объёмы (дорого)\n- Достаточно более простых моделей\n\n**Цена**: $2.50 за 1M input tokens + $10.00 за 1M output tokens\n**Практика**: ~$0.08 за 10-минутное видео (5 кадров)\n\n### Claude 4.5 Sonnet 👑 Премиум\n\n**Когда использовать:**\n- Нужно лучшее качество\n- Сложные задачи анализа\n- Критична точность\n\n**Когда НЕ использовать:**\n- Ограниченный бюджет\n- Большие объёмы видео\n\n**Цена**: $3.00 за 1M input tokens + $15.00 за 1M output tokens\n**Практика**: ~$0.12 за 10-минутное видео (5 кадров)\n\n## Настройка в приложении\n\n### 1. Ollama (локальный)\n\n```bash\n# Установить Ollama\nbrew install ollama # macOS\n\n# Запустить сервис\nollama serve\n\n# Скачать модель\nollama pull moondream2\n```\n\nВ приложении:\n- Settings → AI Analysis → Provider: **Ollama**\n- Model: **moondream2** (или llava)\n- API Key: не требуется\n\n### 2. DeepSeek\n\n1. Зарегистрироваться: https://platform.deepseek.com\n2. API Keys → Create new key\n3. Пополнить баланс: минимум $5\n\nВ приложении:\n- Settings → API Keys → DeepSeek API Key: `sk-...`\n- Settings → AI Analysis → Provider: **DeepSeek**\n- Model: **deepseek-vl**\n\n### 3. OpenAI\n\n1. Зарегистрироваться: https://platform.openai.com\n2. API Keys → Create new secret key\n3. Добавить payment method\n\nВ приложении:\n- Settings → API Keys → OpenAI API Key: `sk-proj-...`\n- Settings → AI Analysis → Provider: **OpenAI**\n- Model: **gpt-4o**\n\n### 4. Claude\n\n1. Зарегистрироваться: https://console.anthropic.com\n2. API Keys → Create key\n3. Пополнить credits\n\nВ приложении:\n- Settings → API Keys → Claude API Key: `sk-ant-...`\n- Settings → AI Analysis → Provider: **Claude**\n- Model: **claude-4-5-sonnet**\n\n## Оптимизация стоимости\n\n### Уменьшить количество кадров\n```typescript\n// Вместо 10 кадров используйте 5\nvlm_num_frames: 5 // Снизит стоимость в 2 раза\n```\n\n### Использовать Fast mode для preview\n```typescript\nperformance_mode: 'Fast' // 3 кадра вместо 5-10\n```\n\n### Комбинировать провайдеры\n```typescript\n// Разработка: Ollama (бесплатно)\n// Production: DeepSeek (дёшево)\n// Критичные проекты: GPT-4o\n```\n\n## Мониторинг расходов\n\n### DeepSeek\nDashboard: https://platform.deepseek.com/usage\n\n### OpenAI\nDashboard: https://platform.openai.com/usage\n\n### Claude\nDashboard: https://console.anthropic.com/settings/usage\n\n## Часто задаваемые вопросы\n\n**Q: Можно ли использовать несколько провайдеров?**\nA: Да, можно переключаться в Settings → AI Analysis\n\n**Q: Какой провайдер самый быстрый?**\nA: Ollama (локально) при наличии GPU, иначе GPT-4o\n\n**Q: Можно ли использовать бесплатно для коммерческих проектов?**\nA: Да, Ollama полностью бесплатен и подходит для commercial use\n\n**Q: Что делать если закончились credits?**\nA: Пополнить баланс на платформе провайдера или переключиться на Ollama\n\n**Q: Можно ли использовать свой сервер Ollama?**\nA: Да, указать custom endpoint в настройках (будет добавлено)\n"}}}}