Template
274 lines
9.2 KiB
Python
274 lines
9.2 KiB
Python
"""OpenRouter API: модели и chat completions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
|
DEFAULT_MODELS = (
|
|
"openai/gpt-4o-mini",
|
|
"google/gemini-2.5-flash",
|
|
"anthropic/claude-3.5-sonnet",
|
|
"deepseek/deepseek-chat",
|
|
"meta-llama/llama-3.3-70b-instruct",
|
|
)
|
|
|
|
DEFAULT_SYSTEM_PROMPT = """Ты — ИИ-поддержка VPN-сервиса (Remnawave / VPN Service).
|
|
Отвечай кратко, по делу, на русском. Помогай с подключением, клиентами, тарифами, балансом, трафиком и устройствами.
|
|
|
|
Правила:
|
|
- Опирайся на FAQ и переписку тикета.
|
|
- Не выдумывай факты о балансе, платежах и сроках подписки пользователя — если нужны точные данные аккаунта, эскалируй.
|
|
- Не обещай возврат денег без эскалации.
|
|
- Не раскрывай внутренние API, ключи, админку.
|
|
- Если вопрос сложный, спорный, про оплату/возврат/бан или ты не уверен — эскалируй человеку.
|
|
|
|
Формат ответа — ТОЛЬКО JSON без markdown:
|
|
{"action":"answer","reply":"текст ответа пользователю"}
|
|
или
|
|
{"action":"escalate","reason":"кратко почему нужен человек"}
|
|
"""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class OpenRouterConfig:
|
|
enabled: bool = False
|
|
api_key: str = ""
|
|
model: str = DEFAULT_MODELS[0]
|
|
models: list[str] = field(default_factory=lambda: list(DEFAULT_MODELS[:3]))
|
|
system_prompt: str = DEFAULT_SYSTEM_PROMPT
|
|
auto_reply: bool = True
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
return bool(self.enabled and self.api_key.strip() and self.model.strip())
|
|
|
|
def models_for_request(self) -> list[str]:
|
|
"""Primary + fallbacks (без дублей)."""
|
|
ordered: list[str] = []
|
|
for mid in [self.model, *self.models]:
|
|
mid = (mid or "").strip()
|
|
if mid and mid not in ordered:
|
|
ordered.append(mid)
|
|
return ordered
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AiDecision:
|
|
action: str # answer | escalate
|
|
reply: str = ""
|
|
reason: str = ""
|
|
model: str = ""
|
|
raw: str = ""
|
|
|
|
|
|
def _truthy(value: str) -> bool:
|
|
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
|
|
|
|
|
|
def load_openrouter_config(db) -> OpenRouterConfig:
|
|
raw = db.get_settings_map(
|
|
[
|
|
"openrouter_enabled",
|
|
"openrouter_api_key",
|
|
"openrouter_model",
|
|
"openrouter_models",
|
|
"openrouter_system_prompt",
|
|
"openrouter_auto_reply",
|
|
]
|
|
)
|
|
models: list[str] = []
|
|
models_raw = (raw.get("openrouter_models") or "").strip()
|
|
if models_raw:
|
|
try:
|
|
parsed = json.loads(models_raw)
|
|
if isinstance(parsed, list):
|
|
models = [str(x).strip() for x in parsed if str(x).strip()]
|
|
except json.JSONDecodeError:
|
|
models = [x.strip() for x in models_raw.split(",") if x.strip()]
|
|
if not models:
|
|
models = list(DEFAULT_MODELS[:3])
|
|
|
|
model = (raw.get("openrouter_model") or "").strip() or (models[0] if models else DEFAULT_MODELS[0])
|
|
prompt = (raw.get("openrouter_system_prompt") or "").strip() or DEFAULT_SYSTEM_PROMPT
|
|
auto_raw = raw.get("openrouter_auto_reply")
|
|
auto_reply = True if auto_raw == "" else _truthy(auto_raw)
|
|
|
|
return OpenRouterConfig(
|
|
enabled=_truthy(raw.get("openrouter_enabled") or ""),
|
|
api_key=(raw.get("openrouter_api_key") or "").strip(),
|
|
model=model,
|
|
models=models,
|
|
system_prompt=prompt,
|
|
auto_reply=auto_reply,
|
|
)
|
|
|
|
|
|
def mask_api_key(key: str) -> str:
|
|
key = (key or "").strip()
|
|
if not key:
|
|
return ""
|
|
if len(key) <= 8:
|
|
return "••••••••"
|
|
return f"{key[:6]}…{key[-4:]}"
|
|
|
|
|
|
def config_public_dict(cfg: OpenRouterConfig) -> dict[str, Any]:
|
|
return {
|
|
"enabled": cfg.enabled,
|
|
"api_key_set": bool(cfg.api_key),
|
|
"api_key_masked": mask_api_key(cfg.api_key),
|
|
"model": cfg.model,
|
|
"models": cfg.models,
|
|
"system_prompt": cfg.system_prompt,
|
|
"auto_reply": cfg.auto_reply,
|
|
"ready": cfg.ready,
|
|
"defaults": list(DEFAULT_MODELS),
|
|
}
|
|
|
|
|
|
def _headers(api_key: str, *, referer: str = "", title: str = "VPN Bot Support") -> dict[str, str]:
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
if referer:
|
|
headers["HTTP-Referer"] = referer
|
|
headers["X-Title"] = title
|
|
return headers
|
|
|
|
|
|
async def list_models(api_key: str, *, referer: str = "") -> list[dict[str, Any]]:
|
|
async with httpx.AsyncClient(timeout=45.0) as client:
|
|
resp = await client.get(
|
|
f"{OPENROUTER_BASE}/models",
|
|
headers=_headers(api_key, referer=referer),
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
items = data.get("data") if isinstance(data, dict) else data
|
|
out: list[dict[str, Any]] = []
|
|
for item in items or []:
|
|
mid = str(item.get("id") or "").strip()
|
|
if not mid:
|
|
continue
|
|
out.append(
|
|
{
|
|
"id": mid,
|
|
"name": str(item.get("name") or mid),
|
|
"context_length": item.get("context_length"),
|
|
"pricing": item.get("pricing") or {},
|
|
}
|
|
)
|
|
out.sort(key=lambda x: x["id"].lower())
|
|
return out
|
|
|
|
|
|
async def chat_completion(
|
|
*,
|
|
api_key: str,
|
|
model: str,
|
|
messages: list[dict[str, str]],
|
|
models: list[str] | None = None,
|
|
referer: str = "",
|
|
temperature: float = 0.3,
|
|
max_tokens: int = 900,
|
|
) -> tuple[str, str]:
|
|
"""Возвращает (text, used_model)."""
|
|
payload: dict[str, Any] = {
|
|
"model": model,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
fallbacks = [m for m in (models or []) if m and m != model]
|
|
if fallbacks:
|
|
payload["models"] = [model, *fallbacks]
|
|
payload["route"] = "fallback"
|
|
|
|
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
resp = await client.post(
|
|
f"{OPENROUTER_BASE}/chat/completions",
|
|
headers=_headers(api_key, referer=referer),
|
|
json=payload,
|
|
)
|
|
if resp.status_code >= 400:
|
|
detail = resp.text[:500]
|
|
raise RuntimeError(f"OpenRouter HTTP {resp.status_code}: {detail}")
|
|
data = resp.json()
|
|
|
|
used = str(data.get("model") or model)
|
|
choices = data.get("choices") or []
|
|
if not choices:
|
|
raise RuntimeError("OpenRouter: пустой ответ")
|
|
message = choices[0].get("message") or {}
|
|
content = message.get("content")
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for part in content:
|
|
if isinstance(part, dict) and part.get("type") == "text":
|
|
parts.append(str(part.get("text") or ""))
|
|
elif isinstance(part, str):
|
|
parts.append(part)
|
|
text = "\n".join(parts).strip()
|
|
else:
|
|
text = str(content or "").strip()
|
|
if not text:
|
|
raise RuntimeError("OpenRouter: пустой content")
|
|
return text, used
|
|
|
|
|
|
_JSON_RE = re.compile(r"\{[\s\S]*\}")
|
|
|
|
|
|
def parse_ai_decision(raw: str, *, used_model: str = "") -> AiDecision:
|
|
text = (raw or "").strip()
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
text = re.sub(r"\s*```$", "", text).strip()
|
|
|
|
payload: dict[str, Any] | None = None
|
|
try:
|
|
payload = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
match = _JSON_RE.search(text)
|
|
if match:
|
|
try:
|
|
payload = json.loads(match.group(0))
|
|
except json.JSONDecodeError:
|
|
payload = None
|
|
|
|
if isinstance(payload, dict):
|
|
action = str(payload.get("action") or "").strip().lower()
|
|
reply = str(payload.get("reply") or payload.get("message") or "").strip()
|
|
reason = str(payload.get("reason") or "").strip()
|
|
if action == "escalate":
|
|
return AiDecision(
|
|
action="escalate",
|
|
reason=reason or reply or "Нужен человек",
|
|
model=used_model,
|
|
raw=raw,
|
|
)
|
|
if action == "answer" and reply:
|
|
return AiDecision(action="answer", reply=reply, model=used_model, raw=raw)
|
|
if reply and not action:
|
|
return AiDecision(action="answer", reply=reply, model=used_model, raw=raw)
|
|
|
|
# Fallback: если модель ответила обычным текстом — считаем ответом
|
|
if len(text) >= 8 and "{" not in text[:20]:
|
|
return AiDecision(action="answer", reply=text, model=used_model, raw=raw)
|
|
return AiDecision(
|
|
action="escalate",
|
|
reason="ИИ вернул неразборчивый ответ",
|
|
model=used_model,
|
|
raw=raw,
|
|
)
|