diff --git a/.env.example b/.env.example index 88594d0..7228a0f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,32 @@ -PERPLEXITY_API_KEY=your_perplexity_api_key_here +# Оставьте незакомментированным только один AI API KEY. +# Если одновременно указать несколько AI ключей, колонка выдаст ошибку. +AI_PROVIDER= + +# Perplexity +# PERPLEXITY_API_KEY=your_perplexity_api_key_here PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat +PERPLEXITY_API_URL=https://api.perplexity.ai/chat/completions + +# OpenAI +# OPENAI_API_KEY=your_openai_api_key_here +OPENAI_MODEL=gpt-4o-mini +OPENAI_API_URL=https://api.openai.com/v1/chat/completions + +# Gemini +# GEMINI_API_KEY=your_gemini_api_key_here +GEMINI_MODEL=gemini-2.5-flash +GEMINI_API_URL=https://generativelanguage.googleapis.com/v1beta/openai/chat/completions + +# Z.ai +# ZAI_API_KEY=your_zai_api_key_here +ZAI_MODEL=glm-5 +ZAI_API_URL=https://api.z.ai/api/paas/v4/chat/completions + +# Anthropic Claude +# ANTHROPIC_API_KEY=your_anthropic_api_key_here +ANTHROPIC_MODEL=claude-sonnet-4-20250514 +ANTHROPIC_API_URL=https://api.anthropic.com/v1/messages +ANTHROPIC_API_VERSION=2023-06-01 DEEPGRAM_API_KEY=your_deepgram_api_key_here PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here PORCUPINE_SENSITIVITY=0.8 diff --git a/README.md b/README.md index bd10fdc..4259d9d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - Follow-up окно 4 секунды после ответа: если пользователь молчит, ассистент возвращается к ожиданию wake word. - Распознавание речи через Deepgram (WebSocket, VAD, fast stop). - Озвучка через Silero TTS (RU + EN, с прерыванием по wake word). -- AI-диалог через Perplexity API со streaming-ответом и контекстом. +- AI-диалог через Perplexity, OpenAI, Gemini, Z.ai и Anthropic Claude API со streaming-ответом и контекстом. - Перевод RU -> EN и EN -> RU. - Погода: текущий прогноз по городу по умолчанию или по названию города. - Таймеры, будильники (включая будни/выходные), секундомеры. @@ -73,11 +73,19 @@ cp .env.example .env Минимально обязательные переменные: ```ini -PERPLEXITY_API_KEY=... +AI_PROVIDER= # опционально; можно оставить пустым +# Раскомментируйте только один AI API KEY: +# PERPLEXITY_API_KEY=... +# OPENAI_API_KEY=... +# GEMINI_API_KEY=... +# ZAI_API_KEY=... +# ANTHROPIC_API_KEY=... DEEPGRAM_API_KEY=... PORCUPINE_ACCESS_KEY=... ``` +Если одновременно оставить несколько AI API key, ассистент вернет ошибку: он не будет выбирать провайдера наугад. + ### 4) Запуск ```bash @@ -92,8 +100,23 @@ python run.py | Переменная | Обязательно | По умолчанию | Назначение | |---|---|---|---| -| `PERPLEXITY_API_KEY` | Да | - | Ключ Perplexity API | +| `AI_PROVIDER` | Нет | `perplexity` | Опциональный провайдер AI (`perplexity`, `openai`, `gemini`, `zai`, `anthropic`; также понимает `claude`) | +| `PERPLEXITY_API_KEY` | Да* | - | Ключ Perplexity API (*если выбран Perplexity и только этот AI ключ активен) | | `PERPLEXITY_MODEL` | Нет | `llama-3.1-sonar-small-128k-chat` | Модель Perplexity | +| `PERPLEXITY_API_URL` | Нет | `https://api.perplexity.ai/chat/completions` | Endpoint Perplexity Chat Completions | +| `OPENAI_API_KEY` | Да* | - | Ключ OpenAI API (*если выбран OpenAI и только этот AI ключ активен) | +| `OPENAI_MODEL` | Нет | `gpt-4o-mini` | Модель OpenAI | +| `OPENAI_API_URL` | Нет | `https://api.openai.com/v1/chat/completions` | Endpoint OpenAI Chat Completions | +| `GEMINI_API_KEY` | Да* | - | Ключ Google Gemini API (*если выбран Gemini и только этот AI ключ активен) | +| `GEMINI_MODEL` | Нет | `gemini-2.5-flash` | Модель Gemini | +| `GEMINI_API_URL` | Нет | `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions` | OpenAI-compatible endpoint Gemini | +| `ZAI_API_KEY` | Да* | - | Ключ Z.ai API (*если выбран Z.ai и только этот AI ключ активен) | +| `ZAI_MODEL` | Нет | `glm-5` | Модель Z.ai | +| `ZAI_API_URL` | Нет | `https://api.z.ai/api/paas/v4/chat/completions` | Endpoint Z.ai Chat Completions | +| `ANTHROPIC_API_KEY` | Да* | - | Ключ Anthropic API (*если выбран Anthropic и только этот AI ключ активен) | +| `ANTHROPIC_MODEL` | Нет | `claude-sonnet-4-20250514` | Модель Claude | +| `ANTHROPIC_API_URL` | Нет | `https://api.anthropic.com/v1/messages` | Endpoint Anthropic Messages API | +| `ANTHROPIC_API_VERSION` | Нет | `2023-06-01` | Версия Anthropic API | | `DEEPGRAM_API_KEY` | Да | - | Ключ Deepgram STT | | `PORCUPINE_ACCESS_KEY` | Да | - | Ключ PicoVoice Porcupine | | `PORCUPINE_SENSITIVITY` | Нет | `0.8` | Чувствительность wake word | @@ -121,6 +144,8 @@ python run.py | Игра | `Давай сыграем в города` | | Управление диалогом | `Повтори`, `Стоп`, `Хватит` | +Память текущего диалога, история сообщений и `ROLE_JSON` системной роли сохраняются для всех поддерживаемых AI-провайдеров. + ## Полезные команды | Команда | Что делает | diff --git a/app/core/ai.py b/app/core/ai.py index e972b3a..90861ab 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -1,11 +1,33 @@ -"""AI module for Perplexity API integration.""" +"""AI module with pluggable providers.""" -# Модуль общения с искусственным интеллектом (Perplexity API). -# Обрабатывает запросы пользователя и переводы. +# Модуль общения с искусственным интеллектом. +# Обрабатывает запросы пользователя и переводы через выбранный API-провайдер. + +import json +import re +from typing import Optional import requests -import re -from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL + +from .config import ( + AI_PROVIDER, + ANTHROPIC_API_KEY, + ANTHROPIC_API_URL, + ANTHROPIC_API_VERSION, + ANTHROPIC_MODEL, + GEMINI_API_KEY, + GEMINI_API_URL, + GEMINI_MODEL, + OPENAI_API_KEY, + OPENAI_API_URL, + OPENAI_MODEL, + PERPLEXITY_API_KEY, + PERPLEXITY_API_URL, + PERPLEXITY_MODEL, + ZAI_API_KEY, + ZAI_API_URL, + ZAI_MODEL, +) _HTTP = requests.Session() @@ -32,10 +54,311 @@ No explanations, no quotes, no comments. Separate variants with " / " (space slash space). Keep the translation максимально кратким и естественным, без лишних слов.""" +_PROVIDER_ALIASES = { + "": "perplexity", + "anthropic": "anthropic", + "claude": "anthropic", + "claude_anthropic": "anthropic", + "gemini": "gemini", + "google": "gemini", + "openai": "openai", + "perplexity": "perplexity", + "z.ai": "zai", + "z-ai": "zai", + "z_ai": "zai", + "zai": "zai", +} + +_PROVIDER_SETTINGS = { + "perplexity": { + "provider": "perplexity", + "protocol": "openai_compatible", + "api_key": PERPLEXITY_API_KEY, + "model": PERPLEXITY_MODEL, + "api_url": PERPLEXITY_API_URL, + "name": "Perplexity", + "key_var": "PERPLEXITY_API_KEY", + "model_var": "PERPLEXITY_MODEL", + }, + "openai": { + "provider": "openai", + "protocol": "openai_compatible", + "api_key": OPENAI_API_KEY, + "model": OPENAI_MODEL, + "api_url": OPENAI_API_URL, + "name": "OpenAI", + "key_var": "OPENAI_API_KEY", + "model_var": "OPENAI_MODEL", + }, + "gemini": { + "provider": "gemini", + "protocol": "openai_compatible", + "api_key": GEMINI_API_KEY, + "model": GEMINI_MODEL, + "api_url": GEMINI_API_URL, + "name": "Gemini", + "key_var": "GEMINI_API_KEY", + "model_var": "GEMINI_MODEL", + }, + "zai": { + "provider": "zai", + "protocol": "openai_compatible", + "api_key": ZAI_API_KEY, + "model": ZAI_MODEL, + "api_url": ZAI_API_URL, + "name": "Z.ai", + "key_var": "ZAI_API_KEY", + "model_var": "ZAI_MODEL", + "extra_headers": { + "Accept-Language": "en-US,en", + }, + }, + "anthropic": { + "provider": "anthropic", + "protocol": "anthropic", + "api_key": ANTHROPIC_API_KEY, + "model": ANTHROPIC_MODEL, + "api_url": ANTHROPIC_API_URL, + "api_version": ANTHROPIC_API_VERSION, + "name": "Anthropic Claude", + "key_var": "ANTHROPIC_API_KEY", + "model_var": "ANTHROPIC_MODEL", + }, +} + + +def _has_active_api_key(value) -> bool: + return bool(str(value or "").strip()) + + +def _normalize_provider_name(provider_name: str) -> str: + normalized = (provider_name or "").strip().lower() + return _PROVIDER_ALIASES.get(normalized, normalized) + + +def _get_provider_settings(): + configured = [ + cfg + for cfg in _PROVIDER_SETTINGS.values() + if _has_active_api_key(cfg.get("api_key")) + ] + if len(configured) == 1: + cfg = configured[0] + requested = _normalize_provider_name(AI_PROVIDER) + if requested and requested in _PROVIDER_SETTINGS and requested != cfg["provider"]: + print( + f"⚠️ AI_PROVIDER={AI_PROVIDER!r} не совпадает с единственным " + f"активным ключом {cfg['name']}. Используем {cfg['name']}." + ) + return cfg, None + + if len(configured) > 1: + names = ", ".join(cfg["name"] for cfg in configured) + return None, ( + "Обнаружено несколько AI API ключей. Оставьте незакомментированным " + f"только один ключ. Сейчас активны: {names}. " + "Колонка не знает, какой AI использовать." + ) + + provider = _normalize_provider_name(AI_PROVIDER) + cfg = _PROVIDER_SETTINGS.get(provider) + if cfg: + return cfg, None + + supported = ", ".join(sorted(_PROVIDER_SETTINGS)) + print( + f"⚠️ Неизвестный AI_PROVIDER={AI_PROVIDER!r}, используем Perplexity. " + f"Поддерживаются: {supported}." + ) + return _PROVIDER_SETTINGS["perplexity"], None + + +def _content_to_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + if text: + parts.append(str(text)) + elif item is not None: + parts.append(str(item)) + return "".join(parts) + if content is None: + return "" + return str(content) + + +def _get_provider_config_error(cfg) -> Optional[str]: + if not cfg: + return "Не настроен AI-провайдер. Проверьте файл .env." + if not cfg["api_key"]: + return f"Не настроен {cfg['key_var']}. Проверьте файл .env." + if not cfg["model"]: + return f"Не настроен {cfg['model_var']}. Проверьте файл .env." + return None + + +def _build_headers(cfg): + if cfg["protocol"] == "anthropic": + return { + "x-api-key": cfg["api_key"], + "anthropic-version": cfg["api_version"], + "Content-Type": "application/json", + } + + headers = { + "Authorization": f"Bearer {cfg['api_key']}", + "Content-Type": "application/json", + } + headers.update(cfg.get("extra_headers") or {}) + return headers + + +def _split_system_messages(messages): + system_parts = [] + chat_messages = [] + + for message in messages: + role = str(message.get("role") or "").strip().lower() + content = _content_to_text(message.get("content")) + if role == "system": + if content: + system_parts.append(content) + continue + + if role not in ("user", "assistant"): + role = "user" + chat_messages.append({"role": role, "content": content}) + + return "\n\n".join(system_parts), chat_messages + + +def _build_payload(cfg, messages, max_tokens, temperature, stream): + if cfg["protocol"] == "anthropic": + system_prompt, chat_messages = _split_system_messages(messages) + payload = { + "model": cfg["model"], + "messages": chat_messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": stream, + } + if system_prompt: + payload["system"] = system_prompt + return payload + + return { + "model": cfg["model"], + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + "stream": stream, + } + + +def _extract_openai_compatible_content(data: dict) -> str: + choices = data.get("choices") or [] + if not choices: + return "" + + message = choices[0].get("message") or {} + return _content_to_text(message.get("content")) + + +def _extract_anthropic_content(data: dict) -> str: + return _content_to_text(data.get("content")) + + +def _extract_response_content(cfg, data: dict) -> str: + if cfg["protocol"] == "anthropic": + return _extract_anthropic_content(data) + return _extract_openai_compatible_content(data) + + +def _iter_openai_compatible_stream(response): + for line in response.iter_lines(decode_unicode=True): + if not line or not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if data_str == "[DONE]": + break + + try: + data_json = json.loads(data_str) + except json.JSONDecodeError: + continue + + choices = data_json.get("choices") or [] + if not choices: + continue + + delta = choices[0].get("delta") or {} + content = delta.get("content", "") + if isinstance(content, str): + if content: + yield content + continue + + if isinstance(content, list): + for item in content: + if isinstance(item, dict): + text = item.get("text") + if text: + yield str(text) + + +def _iter_anthropic_stream(response): + for line in response.iter_lines(decode_unicode=True): + if not line or not line.startswith("data:"): + continue + + data_str = line[5:].strip() + if data_str == "[DONE]": + break + + try: + data_json = json.loads(data_str) + except json.JSONDecodeError: + continue + + chunk_type = data_json.get("type") + if chunk_type == "content_block_start": + content_block = data_json.get("content_block") or {} + text = content_block.get("text") + if text: + yield str(text) + elif chunk_type == "content_block_delta": + delta = data_json.get("delta") or {} + text = delta.get("text") + if text: + yield str(text) + + +def _iter_stream_chunks(cfg, response): + if cfg["protocol"] == "anthropic": + yield from _iter_anthropic_stream(response) + return + + yield from _iter_openai_compatible_stream(response) + + +def _log_request_exception(cfg, error: Exception): + details = "" + response = getattr(error, "response", None) + if response is not None: + body = (response.text or "").strip() + if body: + details = f" | body={body[:400]}" + print(f"❌ Ошибка API ({cfg['name']}): {error}{details}") + def _send_request(messages, max_tokens, temperature, error_text): """ - Внутренняя функция для отправки HTTP-запроса к Perplexity API. + Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру. Args: messages: Список сообщений (история чата). @@ -43,34 +366,33 @@ def _send_request(messages, max_tokens, temperature, error_text): temperature: "Креативность" (0.2 - строго, 1.0 - креативно). error_text: Текст ошибки для пользователя в случае сбоя. """ - if not PERPLEXITY_API_KEY: - return "Не настроен PERPLEXITY_API_KEY. Проверьте файл .env." - headers = { - "Authorization": f"Bearer {PERPLEXITY_API_KEY}", - "Content-Type": "application/json", - } - payload = { - "model": PERPLEXITY_MODEL, - "messages": messages, - "max_tokens": max_tokens, - "temperature": temperature, - "stream": False # Убираем стриминг для более быстрого ответа - } + cfg, selection_error = _get_provider_settings() + if selection_error: + return selection_error + config_error = _get_provider_config_error(cfg) + if config_error: + return config_error try: response = _HTTP.post( - PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15 # Уменьшаем таймаут + cfg["api_url"], + headers=_build_headers(cfg), + json=_build_payload(cfg, messages, max_tokens, temperature, stream=False), + timeout=15, ) - response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx) + response.raise_for_status() data = response.json() - return data["choices"][0]["message"]["content"] + content = _extract_response_content(cfg, data) + if not content: + return "Не удалось обработать ответ от AI." + return content except requests.exceptions.Timeout: - return "Извините, сервер не отвечает. Попробуйте позже." - except requests.exceptions.RequestException as e: - print(f"❌ Ошибка API: {e}") + return f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже." + except requests.exceptions.RequestException as error: + _log_request_exception(cfg, error) return error_text - except (KeyError, IndexError) as e: - print(f"❌ Ошибка парсинга ответа: {e}") + except Exception as error: + print(f"❌ Ошибка парсинга ответа ({cfg['name']}): {error}") return "Не удалось обработать ответ от AI." @@ -109,8 +431,13 @@ def ask_ai_stream(messages_history: list): """ Generator that yields chunks of the AI response as they arrive. """ - if not PERPLEXITY_API_KEY: - yield "Не настроен ключ PERPLEXITY_API_KEY. Проверьте файл .env." + cfg, selection_error = _get_provider_settings() + if selection_error: + yield selection_error + return + config_error = _get_provider_config_error(cfg) + if config_error: + yield config_error return if not messages_history: yield "Извините, я не расслышал вашу команду." @@ -122,46 +449,29 @@ def ask_ai_stream(messages_history: list): if msg["role"] == "user": last_user_message = msg["content"] break - print(f"🤖 Запрос к AI (Stream): {last_user_message}") + print(f"🤖 Запрос к AI ({cfg['name']}, Stream): {last_user_message}") messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history) - headers = { - "Authorization": f"Bearer {PERPLEXITY_API_KEY}", - "Content-Type": "application/json", - } - payload = { - "model": PERPLEXITY_MODEL, - "messages": messages, - "max_tokens": 500, - "temperature": 1.0, - "stream": True, # Enable streaming - } - try: response = _HTTP.post( - PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15, stream=True # Уменьшаем таймаут + cfg["api_url"], + headers=_build_headers(cfg), + json=_build_payload(cfg, messages, 500, 1.0, stream=True), + timeout=15, + stream=True, ) response.raise_for_status() - import json - - for line in response.iter_lines(decode_unicode=True): - if line: - line_text = line - if line_text.startswith("data: "): - data_str = line_text[6:] # Skip "data: " - if data_str == "[DONE]": - break - try: - data_json = json.loads(data_str) - content = data_json["choices"][0]["delta"].get("content", "") - if content: - yield content - except json.JSONDecodeError: - continue - except Exception as e: - print(f"❌ Streaming Error: {e}") + for chunk in _iter_stream_chunks(cfg, response): + yield chunk + except requests.exceptions.Timeout: + yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже." + except requests.exceptions.RequestException as error: + _log_request_exception(cfg, error) + yield "Произошла ошибка связи." + except Exception as error: + print(f"❌ Streaming Error ({cfg['name']}): {error}") yield "Произошла ошибка связи." diff --git a/app/core/config.py b/app/core/config.py index 5947576..b3c8261 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -17,12 +17,47 @@ BASE_DIR = Path(__file__).resolve().parents[2] # Загружаем переменные из файла .env в корневом каталоге load_dotenv(BASE_DIR / ".env") -# --- Настройки AI (Perplexity) --- -# API ключ для доступа к нейросети +# --- Настройки AI --- +# AI_PROVIDER опционален. Приоритет у единственного активного AI API key. +# Если активных ключей несколько, AI-модуль вернет ошибку конфигурации. +AI_PROVIDER = os.getenv("AI_PROVIDER", "perplexity").strip().lower() + +# Perplexity PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY") -# Модель, которую будем использовать (по умолчанию llama-3.1-sonar-small-128k-chat) PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat") -PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions" +PERPLEXITY_API_URL = os.getenv( + "PERPLEXITY_API_URL", "https://api.perplexity.ai/chat/completions" +) + +# OpenAI +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") +OPENAI_API_URL = os.getenv( + "OPENAI_API_URL", "https://api.openai.com/v1/chat/completions" +) + +# Gemini (через официальный OpenAI-compatible endpoint Google) +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") +GEMINI_API_URL = os.getenv( + "GEMINI_API_URL", + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", +) + +# Z.ai +ZAI_API_KEY = os.getenv("ZAI_API_KEY") +ZAI_MODEL = os.getenv("ZAI_MODEL", "glm-5") +ZAI_API_URL = os.getenv( + "ZAI_API_URL", "https://api.z.ai/api/paas/v4/chat/completions" +) + +# Anthropic Claude +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") +ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514") +ANTHROPIC_API_URL = os.getenv( + "ANTHROPIC_API_URL", "https://api.anthropic.com/v1/messages" +) +ANTHROPIC_API_VERSION = os.getenv("ANTHROPIC_API_VERSION", "2023-06-01") # --- Настройки распознавания речи (Deepgram) --- # Ключ для облачного STT (Speech-to-Text)