Add multi-provider AI config safeguards
This commit is contained in:
432
app/core/ai.py
432
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 "Произошла ошибка связи."
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user