Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/chatman-media_timeline-studio.json
T

1 line
205 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<String>,\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<P: AsRef<Path>>(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<Vec<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<ApiKeyType, Vec<String>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<String> {\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<String> {\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<String>,\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<P: AsRef<Path>>(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<Vec<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<ApiKeyType, Vec<String>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<Option<ApiKeyData>> {\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<String> {\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<String> {\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<String>,\n pub service_info: Option<String>, // Дополнительная информация о сервисе\n pub rate_limits: Option<RateLimitInfo>,\n}\n\n/// Информация о лимитах API\n#[derive(Debug, Clone, serde::Serialize)]\npub struct RateLimitInfo {\n pub requests_remaining: Option<u32>,\n pub reset_time: Option<chrono::DateTime<chrono::Utc>>,\n pub daily_limit: Option<u32>,\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<RateLimitInfo> {\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::<chrono::Utc>::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<String>,\n pub service_info: Option<String>, // Дополнительная информация о сервисе\n pub rate_limits: Option<RateLimitInfo>,\n}\n\n/// Информация о лимитах API\n#[derive(Debug, Clone, serde::Serialize)]\npub struct RateLimitInfo {\n pub requests_remaining: Option<u32>,\n pub reset_time: Option<chrono::DateTime<chrono::Utc>>,\n pub daily_limit: Option<u32>,\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<ValidationResult> {\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<RateLimitInfo> {\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::<chrono::Utc>::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<AIMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\n pub system: Option<String>,\n /// 🆕 Tools для Function Calling / Claude Tool Use\n pub tools: Option<Vec<AITool>>,\n /// 🆕 Tool choice strategy\n pub tool_choice: Option<ToolChoice>,\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<AIContentPart>),\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<String>, text: impl Into<String>) -> 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<String>,\n text: impl Into<String>,\n image_data: String,\n media_type: impl Into<String>,\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<String>,\n text: impl Into<String>,\n image_url: impl Into<String>,\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::<Vec<_>>()\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<TokenUsage>,\n pub finish_reason: Option<String>,\n /// 🆕 Tool calls если AI вызвал инструменты\n pub tool_calls: Option<Vec<AIToolCall>>,\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<ClaudeMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\n pub system: Option<String>,\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<ClaudeContent>,\n pub usage: Option<ClaudeUsage>,\n}\n\n/// Контент от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeContent {\n pub r#type: String,\n pub text: Option<String>,\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<ClaudeDelta>,\n pub message: Option<ClaudeMessage>,\n}\n\n/// Дельта изменения в потоке\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeDelta {\n pub r#type: String,\n pub text: Option<String>,\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<OpenAIMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\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<OpenAIContentPart>),\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<OpenAIChoice>,\n pub usage: Option<OpenAIUsage>,\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<String>,\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<OllamaMessage>,\n pub stream: Option<bool>,\n pub options: Option<OllamaOptions>,\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<Vec<String>>,\n}\n\n/// Ollama Options\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaOptions {\n pub temperature: Option<f64>,\n pub num_predict: Option<u32>, // 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<Vec<String>>,\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<String>,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n pub timeout: Option<u64>, // 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<String>,\n pub error: Option<String>,\n}\n\n// ============================================================================\n// CONVERSION IMPLEMENTATIONS\n// ============================================================================\n\nimpl From<ClaudeMessage> 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<AIMessage> 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<OpenAIMessage> 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<AIMessage> 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,<data>\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<OllamaMessage> for AIMessage {\n fn from(msg: OllamaMessage) -> Self {\n AIMessage::text(msg.role, msg.content)\n }\n}\n\nimpl From<AIMessage> 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<String> {\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<Self> {\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<AIMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\n pub system: Option<String>,\n /// 🆕 Tools для Function Calling / Claude Tool Use\n pub tools: Option<Vec<AITool>>,\n /// 🆕 Tool choice strategy\n pub tool_choice: Option<ToolChoice>,\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<AIContentPart>),\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<String>, text: impl Into<String>) -> 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<String>,\n text: impl Into<String>,\n image_data: String,\n media_type: impl Into<String>,\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<String>,\n text: impl Into<String>,\n image_url: impl Into<String>,\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::<Vec<_>>()\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<TokenUsage>,\n pub finish_reason: Option<String>,\n /// 🆕 Tool calls если AI вызвал инструменты\n pub tool_calls: Option<Vec<AIToolCall>>,\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<ClaudeMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\n pub system: Option<String>,\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<ClaudeContent>,\n pub usage: Option<ClaudeUsage>,\n}\n\n/// Контент от Claude\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeContent {\n pub r#type: String,\n pub text: Option<String>,\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<ClaudeDelta>,\n pub message: Option<ClaudeMessage>,\n}\n\n/// Дельта изменения в потоке\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ClaudeDelta {\n pub r#type: String,\n pub text: Option<String>,\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<OpenAIMessage>,\n pub max_tokens: Option<u32>,\n pub temperature: Option<f64>,\n pub stream: Option<bool>,\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<OpenAIContentPart>),\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<OpenAIChoice>,\n pub usage: Option<OpenAIUsage>,\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<String>,\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<OllamaMessage>,\n pub stream: Option<bool>,\n pub options: Option<OllamaOptions>,\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<Vec<String>>,\n}\n\n/// Ollama Options\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct OllamaOptions {\n pub temperature: Option<f64>,\n pub num_predict: Option<u32>, // 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<Vec<String>>,\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<String>,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n pub timeout: Option<u64>, // 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<String>,\n pub error: Option<String>,\n}\n\n// ============================================================================\n// CONVERSION IMPLEMENTATIONS\n// ============================================================================\n\nimpl From<ClaudeMessage> 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<AIMessage> 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<OpenAIMessage> 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<AIMessage> 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,<data>\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<OllamaMessage> for AIMessage {\n fn from(msg: OllamaMessage) -> Self {\n AIMessage::text(msg.role, msg.content)\n }\n}\n\nimpl From<AIMessage> 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<String> {\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<Self> {\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"}}}}