Files
Blind/modules/brain/provider_openrouter.py
2026-09-11 19:47:15 +03:00

138 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Провайдер 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,
)