"""Провайдер 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, )