first commit
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user