138 lines
7.4 KiB
Python
138 lines
7.4 KiB
Python
"""Провайдер LLM через OpenRouter (openai-совместимый API).
|
||
|
||
Сеть (проверено 2026-09-11):
|
||
- OpenRouter API доступен из РФ напрямую (200), НО часть апстрим-провайдеров
|
||
(OpenAI, Google) блокирует запросы с российских IP по своим ToS (403).
|
||
- Решение: опциональный прокси (по умолчанию берётся из .env: OPENROUTER_PROXY),
|
||
запросы выходят через зарубежный egress — тогда доступны все модели.
|
||
|
||
Модель — параметр конструктора (задаётся в тесте/конфиге).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import time
|
||
from typing import Optional
|
||
|
||
from .base import DialogHistory, LlmProvider, LlmResult
|
||
from .clean_text import clean_for_speech
|
||
from .config import get_api_key, get_secret
|
||
from .prompts import SYSTEM_PROMPT
|
||
|
||
|
||
class OpenRouterProvider(LlmProvider):
|
||
name = "openrouter"
|
||
|
||
# Вопросы, требующие свежих данных → модель с суффиксом :online (веб-поиск OpenRouter).
|
||
# Онлайн-поиск медленнее (+4-9 c) и чуть дороже, поэтому только для «актуальных» тем.
|
||
ONLINE_MODEL_ENV = "OPENROUTER_MODEL_ONLINE"
|
||
ONLINE_HINTS = (
|
||
"погод", "курс", "доллар", "евро", "рубл", "новост", "сегодняшн",
|
||
"сейчас на улице", "за окном", "курс валют", "биткоин", "акци",
|
||
"спорт", "счет матча", "результат матч", "последн",
|
||
)
|
||
|
||
def __init__(
|
||
self,
|
||
# openai/* заблокированы ToS-фильтром OpenAI по аккаунту OpenRouter с РФ-биллингом
|
||
# (403 даже через зарубежный egress). Рабочие из РФ (проверено 2026-09-11):
|
||
# google/gemma-3-27b-it, deepseek/deepseek-chat-v3-0324, qwen/qwen-2.5-72b-instruct,
|
||
# mistralai/mistral-small-3.1-24b-instruct, meta-llama/llama-3.3-70b-instruct,
|
||
# z-ai/glm-5.3-flash (reasoning-модель: тратит бюджет max_tokens на размышления).
|
||
# None → берётся OPENROUTER_MODEL из .env (там google/gemma-3-27b-it).
|
||
model: str | None = None,
|
||
online_model: str | None = None, # None → OPENROUTER_MODEL_ONLINE (glm-5.3-flash:online)
|
||
api_key: str | None = None,
|
||
base_url: str = "https://openrouter.ai/api/v1",
|
||
proxy: str | None = None, # http://хост:порт; None → OPENROUTER_PROXY из .env
|
||
temperature: float = 0.6,
|
||
# Reasoning-модели (z-ai/glm-*) тратят этот бюджет на «размышления» ДО ответа
|
||
# (у glm-5.3-flash на простую фразу ушло 65 reasoning-токенов из 78).
|
||
# Если бюджет мал, reasoning съедает его и content приходит ПУСТЫМ.
|
||
# 2000 хватает на длинное стихотворение/историю; это ~$0.0003–0.0008 на ответ.
|
||
max_tokens: int = 2000,
|
||
timeout_sec: float = 30.0,
|
||
) -> None:
|
||
from openai import OpenAI
|
||
|
||
key = api_key or get_api_key()
|
||
if not key:
|
||
raise RuntimeError(
|
||
"Нет API-ключа. Создай файл .env в корне проекта со строкой\n"
|
||
"OPENROUTER_API_KEY=sk-or-v1-...\n"
|
||
"(или export OPENROUTER_API_KEY=... перед запуском)"
|
||
)
|
||
proxy_url = proxy or get_secret("OPENROUTER_PROXY")
|
||
|
||
http_client = None
|
||
if proxy_url:
|
||
import httpx
|
||
http_client = httpx.Client(proxy=proxy_url, timeout=timeout_sec)
|
||
|
||
self._model = model or get_secret("OPENROUTER_MODEL") or "google/gemma-3-27b-it"
|
||
# Модель с веб-поиском для «актуальных» вопросов (погода/курсы/новости)
|
||
self._online_model = (online_model or get_secret(self.ONLINE_MODEL_ENV)
|
||
or "z-ai/glm-5.3-flash:online")
|
||
self._temperature = temperature
|
||
self._max_tokens = max_tokens
|
||
self._client = OpenAI(
|
||
base_url=base_url,
|
||
api_key=key,
|
||
timeout=timeout_sec,
|
||
http_client=http_client,
|
||
)
|
||
|
||
# --- API -------------------------------------------------------------
|
||
def _needs_online(self, question: str, history: Optional[DialogHistory] = None) -> bool:
|
||
"""Похоже ли, что вопрос требует свежих данных из интернета.
|
||
|
||
Смотрим не только на текущую фразу, но и на последние реплики диалога:
|
||
уточнение «я поэтому и спрашиваю...» после вопроса про акции должно
|
||
пойти в online-модель, хотя в самой фразе нет слов «акции/курс».
|
||
"""
|
||
q = question.lower()
|
||
if any(hint in q for hint in self.ONLINE_HINTS):
|
||
return True
|
||
# контекст последних 4 реплик (2 вопроса+ответа) тоже проверяем
|
||
for turn in (history or [])[-4:]:
|
||
text = turn.get("text", "").lower()
|
||
if any(hint in text for hint in self.ONLINE_HINTS):
|
||
return True
|
||
# маркеры продолжения/уточнения («я поэтому и спрашиваю», «а теперь...»)
|
||
if history and re.search(r"\b(поэтому и спрашиваю|я же спрашивал|ты не ответила|"
|
||
r"а теперь|так вот|продолжай|уточню)\b", q):
|
||
return True
|
||
return False
|
||
|
||
def ask(self, question: str, history: Optional[DialogHistory] = None) -> LlmResult:
|
||
system = SYSTEM_PROMPT
|
||
if self.context_block:
|
||
system += "\n\n" + self.context_block
|
||
|
||
messages = [{"role": "system", "content": system}]
|
||
for turn in history or []:
|
||
messages.append({"role": turn["role"], "content": turn["text"]})
|
||
messages.append({"role": "user", "content": question})
|
||
|
||
# Вопрос про погоду/курсы/новости (или уточнение к такому) → модель с веб-поиском
|
||
used_model = self._online_model if self._needs_online(question, history) else self._model
|
||
|
||
t0 = time.perf_counter()
|
||
response = self._client.chat.completions.create(
|
||
model=used_model,
|
||
messages=messages,
|
||
temperature=self._temperature,
|
||
max_tokens=self._max_tokens,
|
||
)
|
||
elapsed = time.perf_counter() - t0
|
||
|
||
raw = response.choices[0].message.content or "" if response.choices else ""
|
||
# Убираем маркеры источников, которые добавляет веб-плагин (ссылки в конце)
|
||
raw = re.sub(r"\s*\b[a-z0-9.-]+\.(?:ru|com|net|org|ai)\b\s*$", "", raw, flags=re.I)
|
||
return LlmResult(
|
||
question=question,
|
||
raw_text=raw.strip(),
|
||
cleaned_text=clean_for_speech(raw),
|
||
generation_sec=elapsed,
|
||
model=used_model,
|
||
) |