first commit

This commit is contained in:
workD12
2026-09-11 19:47:15 +03:00
commit 32754a1fe2
56 changed files with 3836 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
"""Провайдер 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,
)