import asyncio
import html
import logging
import re
import time
import aiohttp
from bot.utils.proxy import get_proxy_connector
from config import (
OPENROUTER_API_KEY,
AI_SYSTEM_PROMPT,
ROUTERAI_API_KEY,
ROUTERAI_BASE_URL,
ROUTERAI_MODEL,
AI_HEALTH_CHECK_ENABLED,
AI_HEALTH_CHECK_INTERVAL,
)
logger = logging.getLogger(__name__)
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
ROUTERAI_URL = f"{ROUTERAI_BASE_URL}/chat/completions"
PAID_NOTICE = "\n\n⚡ Обработано через платный API"
_free_models_cache: list[str] = []
_free_models_cache_time = 0.0
_free_models_cache_ttl = 3600
_working_models_cache: list[str] = []
_working_models_cache_time = 0.0
_working_models_cache_ttl = 600
def get_client_timeout(total: int = 60) -> aiohttp.ClientTimeout:
return aiohttp.ClientTimeout(total=total, sock_connect=15, sock_read=30)
def _md_to_html(text: str) -> str:
"""Convert a small subset of Markdown to Telegram HTML, escaping raw HTML first."""
text = html.escape(text)
def _pre_repl(match: re.Match) -> str:
lang = match.group(1)
code = html.unescape(match.group(2))
return f'
{html.escape(code)}
' if lang else f"{html.escape(code)}"
text = re.sub(r"```(\w*)\n(.*?)```", _pre_repl, text, flags=re.DOTALL)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
text = re.sub(r"~~(.+?)~~", r"\1", text)
text = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', text)
return text
async def _fetch_free_models() -> list[str]:
global _free_models_cache, _free_models_cache_time
now = time.time()
if _free_models_cache and (now - _free_models_cache_time) < _free_models_cache_ttl:
return _free_models_cache
logger.info("Fetching free models from OpenRouter API...")
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
fallback = [
"deepseek/deepseek-v4-flash:free",
"google/gemma-4-26b-a4b-it:free",
"minimax/minimax-m2.5:free",
"qwen/qwen3-next-80b-a3b-instruct:free",
]
connector = get_proxy_connector()
try:
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(
"https://openrouter.ai/api/v1/models",
headers=headers,
timeout=get_client_timeout(30),
) as response:
if response.status == 200:
data = await response.json()
models = data.get("data", [])
free_models = [m.get("id", "") for m in models if m.get("id", "").endswith(":free")]
if free_models:
_free_models_cache = free_models
_free_models_cache_time = now
logger.info("Fetched %d free models", len(free_models))
return free_models
except Exception as exc:
logger.warning("Failed to fetch free models: %s", exc)
finally:
if connector:
await connector.close()
if _free_models_cache:
return _free_models_cache
return fallback
async def _test_model(session: aiohttp.ClientSession, model: str) -> bool:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": "reply OK"}],
"max_tokens": 5,
}
try:
async with session.post(OPENROUTER_URL, json=payload, headers=headers) as response:
if response.status == 200:
data = await response.json()
choices = data.get("choices", [])
return bool(choices and choices[0].get("message", {}).get("content"))
except Exception:
pass
return False
async def _update_working_models() -> None:
global _working_models_cache, _working_models_cache_time
free_models = await _fetch_free_models()
if not free_models:
return
connector = get_proxy_connector()
working: list[str] = []
try:
async with aiohttp.ClientSession(connector=connector) as session:
for model in free_models:
if await _test_model(session, model):
working.append(model)
await asyncio.sleep(0.3)
finally:
if connector:
await connector.close()
_working_models_cache = working
_working_models_cache_time = time.time()
logger.info("Health check: %d/%d models working", len(working), len(free_models))
async def start_model_health_check() -> None:
if not AI_HEALTH_CHECK_ENABLED:
logger.info("Model health check is disabled")
return
await asyncio.sleep(30)
try:
await _update_working_models()
except Exception as exc:
logger.error("Initial health check error: %s", exc)
while True:
await asyncio.sleep(AI_HEALTH_CHECK_INTERVAL)
try:
await _update_working_models()
except Exception as exc:
logger.error("Health check error: %s", exc)
def _log_usage(source: str, model: str, data: dict, latency: float) -> None:
usage = data.get("usage")
if usage:
logger.info(
"AI %s | model=%s in_tok=%s out_tok=%s total_tok=%s latency=%.1fs",
source,
model,
usage.get("prompt_tokens", "?"),
usage.get("completion_tokens", "?"),
usage.get("total_tokens", "?"),
latency,
)
else:
logger.info("AI %s | model=%s latency=%.1fs", source, model, latency)
async def _try_openrouter(
session: aiohttp.ClientSession,
model: str,
headers: dict,
base_payload: dict,
raw: bool = False,
) -> tuple[str | None, bool]:
"""Returns (content, should_retry_later)."""
payload = {**base_payload, "model": model}
start = time.monotonic()
async with session.post(OPENROUTER_URL, json=payload, headers=headers) as response:
latency = time.monotonic() - start
if response.status == 429:
retry_after = response.headers.get("Retry-After")
logger.warning(
"OpenRouter rate limited | model=%s retry_after=%s",
model,
retry_after,
)
return None, True
if response.status != 200:
error_body = await response.text()
logger.warning(
"OpenRouter error | model=%s status=%s latency=%.1fs error=%s",
model,
response.status,
latency,
error_body[:200],
)
return None, False
data = await response.json()
choices = data.get("choices", [])
if not choices:
logger.warning("OpenRouter empty choices | model=%s latency=%.1fs", model, latency)
return None, False
content = choices[0].get("message", {}).get("content")
if not content:
logger.warning("OpenRouter empty content | model=%s latency=%.1fs", model, latency)
return None, False
_log_usage("OpenRouter", model, data, latency)
return content if raw else _md_to_html(content), False
async def _try_routerai(session: aiohttp.ClientSession, messages: list[dict]) -> str | None:
if not ROUTERAI_API_KEY:
logger.warning("RouterAI skipped | key not set")
return None
headers = {
"Authorization": f"Bearer {ROUTERAI_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": ROUTERAI_MODEL,
"messages": messages,
"max_tokens": 512,
}
start = time.monotonic()
async with session.post(ROUTERAI_URL, json=payload, headers=headers) as response:
latency = time.monotonic() - start
if response.status != 200:
error_body = await response.text()
logger.warning(
"RouterAI error | status=%s latency=%.1fs error=%s",
response.status,
latency,
error_body[:200],
)
return None
data = await response.json()
choices = data.get("choices", [])
if not choices:
logger.warning("RouterAI empty choices | latency=%.1fs", latency)
return None
content = choices[0].get("message", {}).get("content")
if not content:
logger.warning("RouterAI empty content | latency=%.1fs", latency)
return None
_log_usage("RouterAI", ROUTERAI_MODEL, data, latency)
return _md_to_html(content) + PAID_NOTICE
async def ask_ai_simple(prompt: str) -> str | None:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
base_payload = {
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512,
}
connector = get_proxy_connector()
try:
async with aiohttp.ClientSession(
timeout=get_client_timeout(), connector=connector
) as session:
free_models = await _fetch_free_models()
for model in free_models[:5]:
result, _ = await _try_openrouter(session, model, headers, base_payload, raw=True)
if result:
# ask_ai_simple returns raw text for internal use (summaries)
return result
logger.warning("ask_ai_simple fallback fail | model=%s", model)
logger.warning("ask_ai_simple | all free models failed, trying RouterAI")
routerai_payload = {
"model": ROUTERAI_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 512,
}
routerai_headers = {
"Authorization": f"Bearer {ROUTERAI_API_KEY}",
"Content-Type": "application/json",
}
start = time.monotonic()
async with session.post(ROUTERAI_URL, json=routerai_payload, headers=routerai_headers) as response:
latency = time.monotonic() - start
if response.status == 200:
data = await response.json()
choices = data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
_log_usage("ask_ai_simple (RouterAI)", ROUTERAI_MODEL, data, latency)
return choices[0]["message"]["content"]
except Exception as exc:
logger.error("ask_ai_simple error: %s", exc)
finally:
if connector:
await connector.close()
return None
async def ask_ai(
prompt: str,
context_messages: list[dict] | None = None,
status_callback=None,
extra_system_content: str | None = None,
) -> str:
system_content = AI_SYSTEM_PROMPT
if extra_system_content:
system_content += "\n\n" + extra_system_content
messages: list[dict] = [{"role": "system", "content": system_content}]
if context_messages:
messages.extend(context_messages)
messages.append({"role": "user", "content": prompt})
or_headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/umb-bot",
"X-Title": "UMB Bot",
}
base_payload = {
"messages": messages,
"max_tokens": 512,
}
connector = get_proxy_connector()
waiting_messages = [
"Думаю...",
"Ой, надо ещё подумать...",
"Секундочку...",
"Ищу ответ...",
"Думаю...",
"Почти готово...",
"Переключаюсь на платный API...",
]
try:
async with aiohttp.ClientSession(
timeout=get_client_timeout(), connector=connector
) as session:
free_models = await _fetch_free_models()
if _working_models_cache and (time.time() - _working_models_cache_time) < _working_models_cache_ttl:
models_to_try = [m for m in _working_models_cache if m in free_models]
if not models_to_try:
models_to_try = free_models
else:
models_to_try = free_models
logger.info("ask_ai | trying %d models", len(models_to_try))
for i, model in enumerate(models_to_try):
if status_callback and i > 0:
wait_idx = min(i, len(waiting_messages) - 1)
await status_callback(waiting_messages[wait_idx])
result, _ = await _try_openrouter(session, model, or_headers, base_payload)
if result:
return result
logger.info("ask_ai | model %s failed, trying next", model)
logger.warning("ask_ai | switching to RouterAI")
if status_callback:
await status_callback(waiting_messages[-1])
paid_result = await _try_routerai(session, messages)
if paid_result:
return paid_result
logger.warning("ask_ai | RouterAI failed too")
logger.error("ask_ai | all models failed")
return "Извини, ни одна модель не смогла ответить. Попробуй позже."
except aiohttp.ClientError as exc:
logger.error("AI request network error: %s", exc)
return "Не удалось связаться с AI сервисом. Проверь соединение."
except asyncio.TimeoutError:
logger.error("ask_ai | timeout after all models exhausted")
return "Сервер AI не ответил вовремя. Попробуй позже."
except Exception:
logger.exception("ask_ai | unexpected error")
return "Произошла ошибка при обработке запроса."
finally:
if connector:
await connector.close()