diff --git "a/bot.py" "b/bot.py" --- "a/bot.py" +++ "b/bot.py" @@ -28,7 +28,7 @@ from pathlib import Path from collections import deque from datetime import date, datetime import mimetypes -from typing import Any +from typing import Any, TypedDict import urllib.parse import urllib.request as _urllib_request from urllib.parse import urlparse @@ -286,40 +286,84 @@ _telegram_session: aiohttp.ClientSession | None = None _http_session: aiohttp.ClientSession | None = None _chat_locks: dict[int, asyncio.Lock] = {} -# Состояние "выключателя" мёртвого Telegram-прокси — см. TG_PROXY_COOLDOWN_SEC выше -# и _looks_like_proxy_garbage/_tg_call/telegram_api_call ниже. -_tg_proxy_down_until: float = 0.0 -_tg_proxy_down_logged_at: float = 0.0 -# ── Выключатель теперь срабатывает по СЧЁТЧИКУ подряд идущих сбоев, а не на -# первый же сбой. Раньше ОДНА-единственная заминка прокси (например разовый -# сетевой глюк на одной ноде anycast-CDN — Vercel/Cloudflare/Deno все матчат -# запросы на множество географически разных нод) полностью глушила ответы -# бота ВСЕМ чатам на TG_PROXY_COOLDOWN_SEC секунд — то есть один случайный сбой -# был неотличим от реально упавшего прокси. Теперь выключатель включается, -# только когда подряд (без единого успеха между ними) накопилось -# TG_PROXY_TRIP_THRESHOLD сбоев — единичные заминки его больше не запускают. -_tg_proxy_consecutive_failures: int = 0 +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: состояние "выключателя" мёртвого Telegram-прокси +# раньше жило как четыре независимых module-level globals (_tg_proxy_down_until/ +# _tg_proxy_down_logged_at/_tg_proxy_consecutive_failures/_tg_proxy_garbage_event_count), +# мутируемых через `global` из двух разных функций (_tg_call/telegram_api_call) — +# такое размазанное состояние сложнее читать и тестировать, чем один объект с +# понятными методами. _TelegramProxyCircuitBreaker ниже — чистая инкапсуляция, +# поведение (включая формулы cooldown/threshold) не изменилось ни на йоту. +# +# Выключатель срабатывает по СЧЁТЧИКУ подряд идущих сбоев, а не на первый же +# сбой. Раньше ОДНА-единственная заминка прокси (например разовый сетевой глюк +# на одной ноде anycast-CDN — Vercel/Cloudflare/Deno все матчат запросы на +# множество географически разных нод) полностью глушила ответы бота ВСЕМ чатам +# на TG_PROXY_COOLDOWN_SEC секунд — то есть один случайный сбой был неотличим +# от реально упавшего прокси. Теперь выключатель включается, только когда +# подряд (без единого успеха между ними) накопилось trip_threshold сбоев — +# единичные заминки его больше не запускают. TG_PROXY_TRIP_THRESHOLD = int(os.getenv("TG_PROXY_TRIP_THRESHOLD", "3")) -# Совокупный (не сбрасывается) счётчик срабатываний "прокси вернул не-JSON" за -# время жизни процесса — виден через /stats, чтобы деградацию прокси можно было -# заметить прямо из Telegram, а не только копаясь в логах контейнера. -_tg_proxy_garbage_event_count: int = 0 - -def _note_proxy_success() -> None: - """Сбрасывает счётчик подряд идущих сбоев — вызывается на любой исход, - который означает, что прокси реально ответил валидным JSON (успех ИЛИ - настоящая ошибка Telegram уровня API), т.е. прокси-звено не виновато.""" - global _tg_proxy_consecutive_failures - _tg_proxy_consecutive_failures = 0 - -def _note_proxy_failure() -> bool: - """Увеличивает счётчик подряд идущих сбоев прокси (и общий счётчик для - /stats). Возвращает True, если достигнут TG_PROXY_TRIP_THRESHOLD и пора - включать выключатель.""" - global _tg_proxy_consecutive_failures, _tg_proxy_garbage_event_count - _tg_proxy_consecutive_failures += 1 - _tg_proxy_garbage_event_count += 1 - return _tg_proxy_consecutive_failures >= TG_PROXY_TRIP_THRESHOLD + +class _TelegramProxyCircuitBreaker: + """Инкапсулирует состояние выключателя — см. комментарий выше. Используется + как единственный module-level инстанс (_tg_proxy_breaker ниже), но методы + не трогают globals напрямую, что делает поведение проще проверять.""" + + def __init__(self, *, cooldown_sec: float, trip_threshold: int) -> None: + self.cooldown_sec = cooldown_sec + self.trip_threshold = trip_threshold + self.down_until: float = 0.0 + self.down_logged_at: float = 0.0 + self.consecutive_failures: int = 0 + # Совокупный (не сбрасывается) счётчик срабатываний "прокси вернул не-JSON" + # за время жизни процесса — виден через /stats, чтобы деградацию прокси + # можно было заметить прямо из Telegram, а не только копаясь в логах контейнера. + self.garbage_event_count: int = 0 + + def is_down(self, now: float) -> bool: + return now < self.down_until + + def log_still_down_if_due(self, now: float) -> None: + """Логирует "прокси всё ещё недоступен" не чаще раза в cooldown_sec — + иначе лавина одинаковых WARNING на каждый пропущенный вызов из бэклога.""" + if now - self.down_logged_at > self.cooldown_sec: + self.down_logged_at = now + log.warning("[telegram] Прокси всё ещё недоступен, пропускаю вызовы ещё ~%.0fс.", self.down_until - now) + + def note_success(self) -> None: + """Сбрасывает счётчик подряд идущих сбоев — вызывается на любой исход, + который означает, что прокси реально ответил валидным JSON (успех ИЛИ + настоящая ошибка Telegram уровня API), т.е. прокси-звено не виновато.""" + self.consecutive_failures = 0 + + def note_failure(self) -> bool: + """Увеличивает счётчик подряд идущих сбоев прокси (и общий счётчик для + /stats). Возвращает True, если достигнут trip_threshold и пора включать + выключатель (см. trip() ниже).""" + self.consecutive_failures += 1 + self.garbage_event_count += 1 + return self.consecutive_failures >= self.trip_threshold + + def trip(self) -> None: + now = time.monotonic() + self.down_until = now + self.cooldown_sec + self.down_logged_at = now + + def status_text(self) -> str: + """Готовый HTML-фрагмент для /stats — раньше собирался в самой команде + по четырём глобалам напрямую, теперь инкапсулирован вместе с состоянием.""" + now = time.monotonic() + if now < self.down_until: + state = f"ВЫКЛЮЧЕН ещё ~{int(self.down_until - now)}с" + else: + state = "в норме" + return ( + f"\n\nTelegram-прокси: {state}\n" + f"Подряд сбоев сейчас: {self.consecutive_failures}/{self.trip_threshold}, " + f"всего за время работы: {self.garbage_event_count}" + ) + +_tg_proxy_breaker = _TelegramProxyCircuitBreaker(cooldown_sec=TG_PROXY_COOLDOWN_SEC, trip_threshold=TG_PROXY_TRIP_THRESHOLD) def _looks_like_proxy_garbage(exc: Exception) -> bool: """Отличает РЕАЛЬНУЮ ошибку Telegram API (валидный JSON вида {"ok": false, ...}) @@ -402,222 +446,28 @@ async def _close_sessions() -> None: # конвертация markdown в html, утилиты json -_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") - -def _split_table_cells(line: str) -> list[str]: - s = line.strip() - if s.startswith("|"): - s = s[1:] - if s.endswith("|"): - s = s[:-1] - return [c.strip() for c in s.split("|")] - -def _convert_markdown_tables_to_lists(text: str) -> str: - """Telegram не рендерит markdown-таблицы НИ В КАКОМ режиме (ни HTML, ни - MarkdownV2) — реальный найденный при тестировании случай: модель (особенно - некоторые модели OpenRouter) игнорирует запрет на таблицы из system_prompt.py - и всё равно генерирует '|---|---|', пользователь видит вместо ��ккуратной - таблицы сырую кашу из символов "|" построчно. Это защитный (второй) рубеж — - находит блоки вида "заголовок + строка-разделитель из дефисов + строки - данных" и разворачивает их в список пунктов "**Заголовок:** значение", - группируя ячейки одной строки в один пункт списка.""" - if "|" not in text or "-" not in text: - return text - lines = text.split("\n") - out: list[str] = [] - i = 0 - n = len(lines) - while i < n: - line = lines[i] - if "|" in line and i + 1 < n and "-" in lines[i + 1] and _TABLE_SEP_RE.match(lines[i + 1]): - header_cells = _split_table_cells(line) - if len(header_cells) >= 2: - data_rows = [] - j = i + 2 - while j < n and "|" in lines[j] and lines[j].strip(): - data_rows.append(_split_table_cells(lines[j])) - j += 1 - if data_rows: - for row in data_rows: - parts = [] - for h_idx, header in enumerate(header_cells): - val = row[h_idx] if h_idx < len(row) else "" - if not val: - continue - parts.append(f"**{header}:** {val}" if header else val) - if parts: - out.append("• " + "; ".join(parts)) - i = j - continue - out.append(line) - i += 1 - return "\n".join(out) - -# ── Защитная сетка от сырого LaTeX ────────────────────────────────────────── -# Реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free выдала -# "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода в ответе про -# площадь круга — при том что system_prompt.py прямо запрещает LaTeX и явно -# перечисляет юникод-замены (см. раздел ФОРМАТИРОВАНИЕ). Инструкция в промпте — -# первый (и ненадёжный) рубеж; это — второй, тот же принцип, что уже применяется -# к случайным HTML-тегам в Phase 0 ниже: не полагаемся только на послушание -# модели, страхуем детерминированной пост-обработкой. -_LATEX_SUPERSCRIPT_MAP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ"} -_LATEX_SUBSCRIPT_MAP = {"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉"} -# Порядок важен: многобуквенные команды (\times, \infty...) должны замениться -# ДО одиночного \t/\i и т.п., иначе оставшийся общий "\команда -> без бэкслеша" -# в конце срежет их раньше времени. dict сохраняет порядок вставки в Python 3.7+. -_LATEX_SYMBOL_MAP: dict[str, str] = { - r"\times": "×", r"\cdot": "·", r"\approx": "≈", r"\infty": "∞", - r"\leq": "≤", r"\le": "≤", r"\geq": "≥", r"\ge": "≥", r"\neq": "≠", r"\ne": "≠", - r"\rightarrow": "→", r"\Rightarrow": "⇒", r"\to": "→", - r"\forall": "∀", r"\exists": "∃", r"\emptyset": "∅", r"\cup": "∪", r"\cap": "∩", r"\in": "∈", - r"\pi": "π", r"\pm": "±", r"\mp": "∓", r"\sum": "∑", r"\int": "∫", r"\prod": "∏", - r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\Gamma": "Γ", r"\theta": "θ", - r"\lambda": "λ", r"\mu": "μ", r"\sigma": "σ", r"\Sigma": "Σ", r"\delta": "δ", r"\Delta": "Δ", - r"\phi": "φ", r"\omega": "ω", r"\Omega": "Ω", -} - -def _latex_superscript(m: re.Match) -> str: - return "".join(_LATEX_SUPERSCRIPT_MAP.get(ch, ch) for ch in m.group(1)) - -def _latex_subscript(m: re.Match) -> str: - return "".join(_LATEX_SUBSCRIPT_MAP.get(ch, ch) for ch in m.group(1)) - -def _scrub_latex(text: str) -> str: - """Конвертирует сырой LaTeX в обычный юникод-текст (или снимает разметку, - если точный эквивалент неизвестен) — ДОЛЖНА вызываться уже после того, как - настоящие блоки/спаны кода вырезаны и заменены плейсхолдерами (см. Phase 1 в - _md_to_html), иначе легитимный код с обратным слэшем (regex-паттерны, пути - Windows и т.п.) был бы испорчен.""" - if "\\" not in text and "$" not in text: - return text - # Разделители-обёртки $$...$$, \[...\], \(...\) — убираем сами разделители, - # оставляя содержимое для дальнейшей посимвольной замены ниже. Одиночный - # "$...$" (инлайн-математика в LaTeX) НАМЕРЕННО не обрабатывается: найдено - # при код-ревью — если в одном сообщении встречаются и сумма в долларах, и - # настоящая формула ("цена $100, а формула $x^2$ рядом"), первый "$" суммы - # ошибочно спаривается с первым "$" формулы, и результат становится ХУЖЕ - # исходного (обрезанные суммы плюс осиротевший "$" в хвосте формулы — то есть - # именно тот класс "лишнего символа", который эта защитная сетка должна - # убирать, а не плодить). "$$...$$" безопаснее: два подряд идущих "$" без - # пробела между ними практически никогда не возникают в обычном тексте с - # суммами денег, поэтому ложные срабатывания здесь на практике не встречаются. - text = re.sub(r"\\\[(.*?)\\\]", r"\1", text, flags=re.DOTALL) - text = re.sub(r"\\\((.*?)\\\)", r"\1", text, flags=re.DOTALL) - text = re.sub(r"\$\$(.*?)\$\$", r"\1", text, flags=re.DOTALL) - # \frac{a}{b} -> a/b (одноуровневая вложенность, самый частый случай) - text = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text) - # \sqrt{x} -> √x, \sqrt[n]{x} -> ⁿ√x - text = re.sub(r"\\sqrt\[([^\]]*)\]\{([^{}]*)\}", r"\1√\2", text) - text = re.sub(r"\\sqrt\{([^{}]*)\}", r"√\1", text) - for cmd, repl in _LATEX_SYMBOL_MAP.items(): - text = text.replace(cmd, repl) - # x^{2} / x^2 -> x², x_{2} / x_2 -> x₂ — только короткие индексы/степени, - # чтобы случайно не тронуть код вида a^b в языках, где это не степень. - # ОСТАТОЧНЫЙ EDGE-CASE (осознанно принят, не фиксим): замена не привязана к - # "$"/"\("-разделителям и срабатывает на голое "x^2" где угодно в тексте вне - # код-блоков/код-спанов (те уже вырезаны на предыдущем шаге). Если модель - # без backtick-форматирования упомянет побитовый XOR в прозе ("5^3 даёт..."), - # это тоже превратится в "5³" — потеряв смысл XOR. Системный промпт и так - # требует оформлять код через `бэктики`/```блоки```, поэтому легитимные - # примеры кода уже защищены; голый "^" в чистой прозе почти всегда всё же - # означает именно степень, а не XOR — компромисс в пользу частого случая. - text = re.sub(r"\^\{([0-9n+\-]{1,3})\}", _latex_superscript, text) - text = re.sub(r"\^([0-9n])(?![0-9])", _latex_superscript, text) - text = re.sub(r"_\{([0-9]{1,3})\}", _latex_subscript, text) - text = re.sub(r"_([0-9])(?![0-9])", _latex_subscript, text) - # Оставшиеся одиночные \command без известного юникод-эквивалента — просто - # снимаем бэкслеш, чтобы пользователь не видел сырое "\int"/"\mathbb" и т.п. - text = re.sub(r"\\([a-zA-Z]+)", r"\1", text) - return text - -# ── Маркеры списков "- текст" / "* текст" в начале строки → "• текст" ─────── -# Реальный найденный при калибровке пробел: _md_to_html конвертирует **bold**, -# *italic*, `code`, ```блоки```, markdown-таблицы — но НЕ конвертирует обычные -# markdown-маркеры списков, которые system_prompt.py явно предписывает -# использовать вместо таблиц ("маркированный список"). Модель пишет "- Пункт" -# или "* Пункт" (оба — совершенно нормальный markdown), а пользователь в -# Telegram видел литеральные "-"/"*" в начале строки вместо аккуратного "•". -# Заменяем маркер целиком (а не оставляем "*" как есть) — так исключается и -# побочный риск, что одиночная "*" в начале строки случайно спарится с другой -# "*" где-то дальше в тексте и даст неверный *italic*. -_BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE) - -def _normalize_bullet_markers(text: str) -> str: - return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text) - -def _md_to_html(text: str) -> str: - """Convert markdown-like text to Telegram HTML. - Code blocks are saved first so underscores/asterisks inside them - are never treated as italic/bold markers. - """ - if not text: - return "" - - # ── Phase 0: нормализация "сырых" HTML-тегов, которые модель иногда пишет - # напрямую вместо markdown (несмотря на явную инструкцию в system_prompt.py - # использовать только markdown-синтаксис) — без этого такие теги ловятся - # escape'ом на шаге 2 и показываются пользователю как видимый мусорный текст - # вида ""/"" прямо в сообщении (реальный найденный при тестировании - # баг). Сначала убираем заведомо битые self-closing варианты (напр. ""), - # затем конвертируем корректные парные теги в markdown-эквивалент — дальше - # они идут по тому же (уже проверенному) конвейеру, что и обычный markdown. - text = re.sub(r"", "", text, flags=re.IGNORECASE) - text = re.sub(r"<(?:b|strong)>(.*?)", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"<(?:i|em)>(.*?)", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"(.*?)", r"\1", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"(.*?)", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"
(.*?)
", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL) - text = re.sub(r"(.*?)", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL) - - # ── Phase 1: Save code spans/blocks before any processing ──────────────── - _saved: dict[str, str] = {} - _counter = [0] - - def _save_block(m: re.Match) -> str: - key = f"\x00CB{_counter[0]}\x00" - _counter[0] += 1 - _saved[key] = m.group(0) - return key - - text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", _save_block, text, flags=re.DOTALL) - text = re.sub(r"`[^`\n]+`", _save_block, text) - - # ── Phase 1.3: сырой LaTeX → юникод (см. _scrub_latex выше) — код уже - # вынесен на предыдущем шаге, поэтому обратные слэши в реальном коде - # (regex, пути Windows и т.п.) не затрагиваются. - text = _scrub_latex(text) - - # ── Phase 1.4: маркеры списков "- "/"* " → "• " (см. _normalize_bullet_ - # markers выше) — ДО таблиц и ДО Phase 3, чтобы не путаться с "**bold**" и - # чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой. - text = _normalize_bullet_markers(text) - - # ── Phase 1.5: markdown-таблицы → список пунктов (см. _convert_markdown_ - # tables_to_lists выше) — код уже вынесен на предыдущем шаге, поэтому "|" - # внутри кода (например, битовое ИЛИ в Rust/C) сюда не попадёт. - text = _convert_markdown_tables_to_lists(text) - - # ── Phase 2: HTML-escape the rest ──────────────────────────────────────── - text = text.replace("&", "&").replace("<", "<").replace(">", ">") - - # ── Phase 3: Apply markdown ─────────────────────────────────────────────── - text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text, flags=re.DOTALL) - text = re.sub(r"(\*|_)(.*?)\1", r"\2", text) - text = re.sub(r"~~(.*?)~~", r"\1", text) - - # ── Phase 4: Restore code blocks with proper escaping ──────────────────── - for key, orig in _saved.items(): - if orig.startswith("```"): - m = re.match(r"```[a-zA-Z0-9]*\n(.*)\n```", orig, re.DOTALL) - inner = m.group(1) if m else orig[3:-3] - else: - inner = orig[1:-1] - inner = inner.replace("&", "&").replace("<", "<").replace(">", ">") - tag = "pre" if orig.startswith("```") else "code" - text = text.replace(key, f"<{tag}>{inner}") - - return text +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: вся логика конвертации markdown/LaTeX/таблиц/ +# маркеров списков в Telegram HTML вынесена в отдельный модуль lumen_formatting.py — +# это чистые функции над строками без единой зависимости от Telegram/Gemini/ +# OpenRouter/рантайм-состояния бота, самый безопасный кандидат на выделение из +# монолитного bot.py. Публичные имена и поведение не изменились — импортируются +# напрямую, чтобы `bot._md_to_html(...)`/`bot._scrub_latex(...)` и т.п. продолжали +# работать ровно как раньше (в т.ч. для существующих тестов). +# +# Только `_md_to_html`/`_scrub_latex`/`_normalize_bullet_markers` реально нужны +# здесь (используются в коде bot.py или напрямую в тестах через `bot.X`) — +# остальные внутренние хелперы (`_TABLE_SEP_RE`, `_LATEX_SYMBOL_MAP` и т.п.) +# нужны только САМОЙ `_md_to_html` внутри lumen_formatting.py и импортировать их +# сюда незачем (pyflakes справедливо ловил их как "imported but unused"). +from lumen_formatting import _scrub_latex, _normalize_bullet_markers, _md_to_html + +# `_scrub_latex`/`_normalize_bullet_markers` не вызываются напрямую нигде в +# ОСТАЛЬНОМ коде bot.py (их использует только сама `_md_to_html` внутри +# lumen_formatting.py) — но остаются нужны как `bot._scrub_latex(...)`/ +# `bot._normalize_bullet_markers(...)` для существующих тестов. `__all__` — это +# единственный способ сообщить голому `pyflakes` (без flake8, `# noqa` им не +# распознаётся), что это намеренный ре-экспорт, а не забытый мёртвый импорт. +__all__ = ["_scrub_latex", "_normalize_bullet_markers"] _PRUNE_SENTINEL = object() @@ -639,24 +489,22 @@ def _json_prune_defaults(val: Any) -> Any: # безопасные обёртки над вызовами telegram async def _tg_call(method: Any, *args: Any, call_timeout: float | None = None, retries: int = 1, **kwargs: Any) -> Any: - global _tg_proxy_down_until, _tg_proxy_down_logged_at now = time.monotonic() - if now < _tg_proxy_down_until: + if _tg_proxy_breaker.is_down(now): # Прокси уже недавно помечен недоступным (см. срабатывание ниже) — не бьёмся # заново в мёртвый прокси на каждое сообщение из бэклога, тихо возвращаем None, # как будто вызов не удался (вызывающий код и так умеет это обрабатывать). - # Лог пишем не чаще раза в TG_PROXY_COOLDOWN_SEC, а не на каждый пропущенный - # вызов — иначе тот же лавинный спам никуда не денется, просто сменит текст. - if now - _tg_proxy_down_logged_at > TG_PROXY_COOLDOWN_SEC: - _tg_proxy_down_logged_at = now - log.warning("[telegram] Прокси всё ещё недоступен, пропускаю вызовы ещё ~%.0fс.", _tg_proxy_down_until - now) + # Лог пишем не чаще раза в TG_PROXY_COOLDOWN_SEC (см. log_still_down_if_due), + # а не на каждый пропущенный вызов — иначе тот же лавинный спам никуда не + # денется, просто сменит текст. + _tg_proxy_breaker.log_still_down_if_due(now) return None last_exc = None timeout_val = call_timeout if call_timeout is not None else 35.0 for attempt in range(retries + 1): try: result = await asyncio.wait_for(method(*args, **kwargs), timeout=timeout_val) - _note_proxy_success() + _tg_proxy_breaker.note_success() return result except asyncio.CancelledError: raise @@ -669,7 +517,7 @@ async def _tg_call(method: Any, *args: Any, call_timeout: float | None = None, r # семантический не-op), а не признак сбоя прокси-звена — засчитываем как # успех, иначе безобидные повторные edit_text с тем же текстом ложно # накручивали бы счётчик сбоев прокси. - _note_proxy_success() + _tg_proxy_breaker.note_success() return None if last_exc is not None and _looks_like_proxy_garbage(last_exc): # Не Telegram ответил ошибкой, а прокси перед ним отдал не-JSON (см. @@ -679,28 +527,26 @@ async def _tg_call(method: Any, *args: Any, call_timeout: float | None = None, r # сразу включаем выключатель — единичная заминка на одной ноде anycast- # CDN не должна глушить ответы бота всем чатам целиком (см. историю # проекта: именно так один разовый глюк выглядел как "бот не отвечает"). - tripped = _note_proxy_failure() + tripped = _tg_proxy_breaker.note_failure() if tripped: - _tg_proxy_down_until = time.monotonic() + TG_PROXY_COOLDOWN_SEC - _tg_proxy_down_logged_at = time.monotonic() + _tg_proxy_breaker.trip() log.warning( "[telegram] Прокси вернул не-JSON ответ %d раз(а) подряд (порог %d) — " "включаю паузу на %.0fс. Проверьте доступность %s.", - _tg_proxy_consecutive_failures, TG_PROXY_TRIP_THRESHOLD, TG_PROXY_COOLDOWN_SEC, + _tg_proxy_breaker.consecutive_failures, _tg_proxy_breaker.trip_threshold, _tg_proxy_breaker.cooldown_sec, TELEGRAM_API_BASE_URL, ) else: log.warning( "[telegram] Прокси вернул не-JSON ответ (%d/%d подряд, выключатель ещё не сработал).", - _tg_proxy_consecutive_failures, TG_PROXY_TRIP_THRESHOLD, + _tg_proxy_breaker.consecutive_failures, _tg_proxy_breaker.trip_threshold, ) return None log.warning("[telegram] call failed: %s", last_exc) return None async def telegram_api_call(method: str, payload: dict, *, request_timeout: float | None = None) -> Any: - global _tg_proxy_down_until, _tg_proxy_down_logged_at - if time.monotonic() < _tg_proxy_down_until: + if _tg_proxy_breaker.is_down(time.monotonic()): raise RuntimeError(f"Telegram API {method}: прокси сейчас помечен недоступным (см. предыдущие [telegram] предупреждения), не дёргаю сеть повторно.") url = f"{TELEGRAM_API_BASE_URL}/bot{BOT_TOKEN}/{method}" session = await _get_telegram_session() @@ -715,27 +561,26 @@ async def telegram_api_call(method: str, payload: dict, *, request_timeout: floa exc_str = exc_str.replace(BOT_TOKEN, "") if _looks_like_proxy_garbage(exc): # См. комментарий в _tg_call — выключатель теперь срабатывает по счётчику - # подряд идущих сбоев (TG_PROXY_TRIP_THRESHOLD), а не на первый же сбой. - tripped = _note_proxy_failure() + # подряд идущих сбоев (см. _TelegramProxyCircuitBreaker), а не на первый же сбой. + tripped = _tg_proxy_breaker.note_failure() if tripped: - _tg_proxy_down_until = time.monotonic() + TG_PROXY_COOLDOWN_SEC - _tg_proxy_down_logged_at = time.monotonic() + _tg_proxy_breaker.trip() log.warning( "[telegram] Прокси недоступен при вызове %s %d раз(а) подряд (порог %d) — пауза на %.0fс.", - method, _tg_proxy_consecutive_failures, TG_PROXY_TRIP_THRESHOLD, TG_PROXY_COOLDOWN_SEC, + method, _tg_proxy_breaker.consecutive_failures, _tg_proxy_breaker.trip_threshold, _tg_proxy_breaker.cooldown_sec, ) else: log.warning( "[telegram] Прокси недоступен при вызове %s (%d/%d подряд, выключатель ещё не сработал).", - method, _tg_proxy_consecutive_failures, TG_PROXY_TRIP_THRESHOLD, + method, _tg_proxy_breaker.consecutive_failures, _tg_proxy_breaker.trip_threshold, ) raise RuntimeError(f"Network error in telegram_api_call for {method}: {exc_str}") from None if not isinstance(data, dict) or not data.get("ok"): # Прокси round-trip'нул нормально и вернул валидный JSON — сам факт, что # Telegram ответил "ok: false", НЕ вина прокси-звена, засчитываем успех. - _note_proxy_success() + _tg_proxy_breaker.note_success() raise RuntimeError(f"Telegram API {method} failed: {data}") - _note_proxy_success() + _tg_proxy_breaker.note_success() return data["result"] def is_guest_message(message: Message | dict) -> bool: @@ -892,19 +737,38 @@ def _classify_model_error(status: int | None, text: str) -> str: return "unavailable" return "other" +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше _or_error_msg и _gemini_error_msg держали +# почти идентичную классификацию (rate_limit/paid/forbidden/unavailable) в двух +# отдельных функциях с чуть разным текстом — риск, что при будущей правке кто-то +# поправит формулировку в одной и забудет про другую (ровно то дублирование, +# которого проект и так избегает в других местах, см. _next_fallback_model выше). +# Общий источник текста для обеих — эта таблица; провайдер-специфичной разницы +# в тексте больше нет и намеренно: сообщение об ошибке НЕ должно называть +# "резервного провайдера" — с автоматическим роутером (см. "автоматический выбор +# модели" ниже) OpenRouter сплошь и рядом оказывается ПЕРВЫМ, а не резервным +# кандидатом, так что старая формулировка была не просто лишней деталью +# реализации, а фактически неверной. Упоминания команд /model и /provider тоже +# убраны целиком — обе команды удалены (см. README, "Автоматический выбор +# модели"), реального способа переключиться вручную больше нет, и предлагать +# его пользователю было прямой (и активно вводящей в заблуждение) ошибкой. +_MODEL_ERROR_MESSAGES: dict[str, str] = { + "rate_limit": "Лимит запросов для этой модели сейчас исчерпан. Подождите немного и попробуйте ещё раз — бот сам подберёт другую модель.", + "paid": "Эта модель сейчас недоступна. Попробуйте повторить запрос — бот сам подберёт другую модель.", + "forbidden": "Временная ошибка доступа к сервису. Попробуйте ещё раз.", + "unavailable": "Эта модель сейчас недоступна. Попробуйте повторить запрос — бот сам подберёт другую модель.", +} +_MODEL_ERROR_FALLBACK_MSG = "Временная ошибка сервиса. Попробуйте ещё раз через некоторое время." + +def _model_error_text(kind: str) -> str: + return _MODEL_ERROR_MESSAGES.get(kind, _MODEL_ERROR_FALLBACK_MSG) + def _or_error_msg(e: Exception, kind: str) -> str: - txt = _error_text(e).strip() or e.__class__.__name__ - status = _error_status(e, txt) - if status == 429: - return "Лимит запросов у резервного провайдера исчерпан. Выберите другую бесплатную модель через /provider." - if status in {400, 404}: - return "Эта модель сейчас недоступна или требует платного доступа. Выберите другую модель через /provider." - if status in {401, 403}: - return "Временная проблема с доступом к резервному провайдеру. Попробуйте ещё раз чуть позже." # Сырой текст ошибки API сюда намеренно не подставляется (может содержать # внутренние детали инфраструктуры, HTML/JSON или обрывки заголовков) — # то же правило, что уже применяется в _gemini_error_msg ниже. - return "Ошибка резервного провайдера. Попробуйте ещё раз или выберите другую модель через /provider." + txt = _error_text(e).strip() or e.__class__.__name__ + status = _error_status(e, txt) + return _model_error_text(_classify_model_error(status, txt)) class GeminiAllModelsExhaustedError(RuntimeError): """Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей @@ -928,156 +792,60 @@ def _gemini_error_msg(e: Exception, model_id: str) -> str: if isinstance(e, GeminiAllModelsExhaustedError): return ( "Лимит бесплатных запросов исчерпан для всех доступных моделей — " - "это реальный суточный лимит, а не баг. Попробуйте позже или " - "переключитесь на резервный провайдер (/provider)." + "это реальный суточный лимит, а не баг. Попробуйте позже." ) txt = _error_text(e).strip() or e.__class__.__name__ status = _error_status(e, txt) kind = _classify_model_error(status, txt) log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status) # Реальное имя модели (например "Gemini 3.5 Flash") сюда намеренно не - # подставляется: в /model его показывать можно (это осознанно открытое - # техническое меню выбора), а вот в сообщении об ошибке посреди обычного - # диалога это выглядело бы как случайная утечка бренда/вендора. - if kind == "rate_limit": - return "Лимит запросов для текущей модели временно исчерпан. Подождите немного или выберите другую модель через /model." - if kind == "paid": - return "Текущая модель сейчас недоступна. Попробуйте другую через /model." - if kind == "forbidden": - return "Временная ошибка доступа к сервису. Попробуйте ещё раз." - if kind == "unavailable": - return "Текущая модель временно недоступна. Попробуйте другую через /model." - # Не показываем сырой ответ API (может содержать HTML, JSON, токены) - return "Временная ошибка сервиса. Попробуйте ещё раз или выберите другую модель через /model." + # подставляется — в сообщении об ошибке посреди обычного диалога это + # выглядело бы как случайная утечка бренда/вендора (см. защиту от утечки + # идентичности ниже). Не показываем и сырой ответ API (может содержать + # HTML, JSON, токены) — см. общие шаблоны _MODEL_ERROR_MESSAGES выше. + return _model_error_text(kind) # список моделей +# +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: конфигурация моделей и логика построения маршрута +# (GEMINI_MODELS, TEXT_MODEL_ORDER, единый реестр "нездоровых" моделей +# _OR_MODEL_HEALTH/_ROUTER_EXCLUDED_OR_MODELS, порядок моделей OpenRouter, +# цепочки Gemini, эвристики "тяжёлый запрос?"/"нужна свежая информация?" и сами +# _build_route/_or_route/_gemini_route) вынесены в lumen_router_config.py — это +# чистые конфигурация+функции принятия решения без единого обращения к Telegram/ +# Gemini/OpenRouter API, поэтому безопасный кандидат на отдельный модуль (в +# отличие от ask_gemini/_run_route ниже, которые реально ИСПОЛНЯЮТ маршрут и +# остаются здесь). Импорт стоит именно тут (там, где раньше физически начиналось +# определение GEMINI_MODELS) для консистентности с историей файла, хотя строгой +# необходимости в этом больше нет: _LEAK_LITERAL_STRINGS (которая раньше требовала +# GEMINI_MODELS/TEXT_MODEL_ORDER на уровне модуля именно в этой точке файла) +# теперь целиком строится внутри lumen_security.py, а не здесь. +# Публичные имена и поведение не изменились. Импортируются только реально +# используемые здесь (в коде bot.py или напрямую в тестах через `bot.X`) имена — +# например, `_gemini_route`/`_OR_HEAVY_ORDER`/`TEXT_MODEL_ORDER` нужны только +# САМОЙ `_build_route` внутри lumen_router_config.py, а не bot.py. +from lumen_router_config import ( + GEMINI_MODELS, + DEFAULT_GEMINI_MODEL, + _check_unconfirmed_model_quotas, + _OR_MODEL_HEALTH, + _ROUTER_EXCLUDED_OR_MODELS, + _check_temporary_free_models_expiry, + _or_route, + _OR_LIGHT_ORDER, + GEMINI_HEAVY_CHAIN, + GEMINI_SEARCH_CHAIN, + GEMINI_DEFAULT_CHAIN, + _looks_like_heavy_query, + _looks_like_freshness_query, + _build_route, +) -# name/badge/desc/public_name/public_desc, ранее ��крашавшие каждую запись здесь, -# убраны целиком (ponytail-audit, июль 2026) — это были чисто отображаемые строки -# для команды /model, которая с тех пор удалена (см. "автоматический выбор модели" -# ниже); ни одно из них нигде не читалось. Настоящая модель, которую обозначает -# каждый ключ, и так понятна по самому ключу и по комментариям ниже — ничего не -# потеряно. Единственные поля, которые здесь реально используются: search_grounding/ -# map_grounding/url_context/no_search/no_system/stream (см. _build_gemini_call_config) -# и quota_unconfirmed (см. _check_unconfirmed_model_quotas). -GEMINI_MODELS: dict[str, dict[str, Any]] = { - # Gemini 3.6 Flash — новый флагман линейки Flash, вышел 21 июля 2026, сменяет - # 3.5 Flash: по анонсу Google лучше в коде/агентных сценариях/мультимодальности, - # ~17% экономичнее по токенам. Контекст 1 млн токенов, знания по март 2026. - "gemini-3.6-flash": { - "stream": True, - # ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026, реальный скриншот от - # владельца): Map grounding = 0/0 (квоты нет вовсе). Search grounding в - # дашборде числится не по конкретной модели, а по общему бакету "Gemini 3" - # (объединяет 3/3.1/3.5/3.6) — и этот бакет тоже 0/0, то есть поиска нет ни - # у одной модели поколения Gemini 3.x на этом ключе (в отличие от бакета - # "Gemini 2.5" — там реально 21/1500, см. gemini-2.5-flash/lite ниже и - # обновлённый порядок GEMINI_SEARCH_CHAIN). RPD-лимит тоже подтверждён: 20/сутки. - # Прежнее консервативное предположение (0/0 по аналогии с 3.5-flash) оказалось - # верным — quota_unconfirmed снят. - "search_grounding": False, "map_grounding": False, "url_context": True, - }, - # Gemini 3.5 Flash — прошлый флагман линейки Flash, сохранён в цепочке как - # резерв после 3.6 Flash. - "gemini-3.5-flash": { - "stream": True, - # По данным дашборда AI Studio (июль 2026): Map grounding для этой модели - # показывает лимит 0/0 — то есть бесплатной квоты на инструмент нет вообще - # (не "не расходовано", а именно нулевой лимит). Search grounding отдельно - # для 3.5/3-flash не выделен в дашборде (числится под "Gemini 3" с тем же 0/0) — - # отключаем оба инструмента для этой модели, чтобы не тратить попытки впустую. - # url_context — другое дело: у него нет отдельной дневной квоты в дашборде, - # он просто добавляет токены по обычной цене модели, поэтому оставляем включённым. - "search_grounding": False, "map_grounding": False, "url_context": True, - }, - # Gemini 3 Flash Preview — предыдущая Preview-версия линейки Flash, сохранена - # для тех, кто предпочитает её поведение версии 3.5 (более активное обдумывание). - "gemini-3-flash-preview": { - "stream": True, - "search_grounding": False, "map_grounding": False, "url_context": True, - }, - # Gemini 3.5 Flash-Lite — новая версия самой быстрой и экономичной модели, - # вышла 21 июля 2026 вместе с 3.6 Flash; превосходит 3.1 Flash-Lite в агентных - # задачах и длинном контексте, до 350 токенов/сек. - "gemini-3.5-flash-lite": { - "stream": True, - # ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026). Map grounding — реально - # ненулевая квота 500/сутки, прежнее предположение (True) подтвердилось. - # Search grounding — ИСПРАВЛЕНО: раньше здесь стояло True по неверной аналогии - # с gemini-3.1-flash-lite ("раз lite-класс, значит есть квота на оба - # инструмента"). По факту дашборд считает Search grounding не по конкретной - # модели, а по общему бакету поколения — "Gemini 3" (охватывает 3/3.1/3.5/3.6 - # разом) — и этот бакет 0/0. Реальная квота на поиск есть только у бакета - # "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite и обновлённый порядок - # GEMINI_SEARCH_CHAIN. quota_unconfirmed снят. - "search_grounding": False, "map_grounding": True, - }, - # Gemini 3.1 Flash-Lite — прошлая версия самой быстрой и экономичной модели - # линейки, сохранена в цепочке как резерв после 3.5 Flash-Lite. - "gemini-3.1-flash-lite": { - "stream": True, - # Дашборд показывает реальную ненулевую квоту на Map grounding (0/500) для - # этой модели — map_grounding оставлен включённым. ИСПРАВЛЕНО (24 июля 2026): - # search_grounding раньше тоже стоял True — это была та же ошибка, что и у - # gemini-3.5-flash-lite ("есть квота на map grounding → значит есть и на - # search"), но дашборд считает Search grounding отдельным общим бакетом по - # ПОКОЛЕНИЮ модели ("Gemini 3" — охватывает 3/3.1/3.5/3.6 разом), и этот - # бакет показывает 0/0. Реальная квота на поиск подтверждена только у бакета - # "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite ниже. - "search_grounding": False, "map_grounding": True, - }, - # Gemini 2.5 Flash — универсальная мультимодальная модель поколения 2.5, - # хороший баланс скорости и качества для большинства повседневных задач. - "gemini-2.5-flash": { - "stream": True, - "search_grounding": True, "map_grounding": True, - }, - # Gemini 2.5 Flash-Lite — экономичная модель поколения 2.5 для задач, где - # важна скорость ответа больше, чем глубина рассуждений. - "gemini-2.5-flash-lite": { - "stream": True, - "search_grounding": True, "map_grounding": True, - }, - # Gemma 4 31B — флагманская открытая модель Google на 31 млрд параметров. - "gemma-4-31b-it": { - "no_system": True, "no_search": True, "stream": True, - }, - # Gemma 4 26B — компактная открытая модель Google на 26 млрд параметров с - # расширенным мышлением (thinking). - "gemma-4-26b-a4b-it": { - "no_system": True, - # НАЙДЕНО при перепроверке конфига (24 июля 2026): у "родственной" модели - # gemma-4-31b-it выше стоит "no_search": True (Gemma, как открытая модель, - # не поддерживает grounding-инструменты Gemini API в принципе), а здесь этот - # флаг был случайно пропущен. Без него _build_gemini_call_config по умолчанию - # (search_grounding/url_context по умолчанию True при отсутствии ключа в конфиге) - # пытался бы добавить в запрос google_search И url_context для модели, которая - # их не поддерживает вообще — реальный риск ошибки API на КАЖДЫЙ вызов этой - # модели (она сейчас последняя в GEMINI_HEAVY_CHAIN, поэтому баг маловероятно - # проявлялся на практике, но был реальным). Добавлено для консистентности с 31B. - "no_search": True, "stream": True, - }, -} -DEFAULT_GEMINI_MODEL = "gemini-3.6-flash" - -def _check_unconfirmed_model_quotas() -> None: - """Модели, добавленные сразу после релиза (см. quota_unconfirmed=True в - GEMINI_MODELS), — их реальные RPD-лимиты и доступность search/map grounding - ещё не подтверждены по дашборду AI Studio (дашборд обновляется с задержкой - после релиза модели, иногда на несколько дней). Громко напоминаем при - каждом старте, пока флаг не снят вручную после реальной проверки — та же - идея, что и у _check_temporary_free_models_expiry выше, только для новых, - а не для истекающих моделей.""" - for mid, conf in GEMINI_MODELS.items(): - if conf.get("quota_unconfirmed"): - log.warning( - "[setup] SYSTEM WARN: реальные RPD-лимиты и доступность search/map grounding " - "для модели %s ещё НЕ подтверждены по дашборду AI Studio (модель недавно " - "выпущена) — текущие search_grounding/map_grounding в GEMINI_MODELS это " - "предположение по аналогии с моделью того же класса. Проверьте дашборд и " - "уберите 'quota_unconfirmed' у этой модели в bot.py, поправив конфиг при необходимости.", - mid, - ) +# См. пояснение про __all__ у первого блока (lumen_formatting) выше — эти +# конкретные имена нужны только как `bot.X` для тестов, в остальном коде bot.py +# не используются напрямую (используются только внутри самой _build_route, +# которая целиком живёт в lumen_router_config.py). +__all__ += ["_OR_MODEL_HEALTH", "_ROUTER_EXCLUDED_OR_MODELS", "_or_route", "GEMINI_HEAVY_CHAIN", "GEMINI_SEARCH_CHAIN"] def get_system_prompt(model_id: str | None = None) -> str: @@ -1094,7 +862,22 @@ def get_system_prompt(model_id: str | None = None) -> str: ) return dynamic_header + SYSTEM_PROMPT -chat_state: dict[int, dict[str, Any]] = {} +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одного элемента chat_state[chat_id] раньше +# нигде не была описана явно — она собиралась по кусочкам из трёх разных мест +# (get_state/_restore_single_chat/_serialize_chat_state), и чтобы понять "из чего +# вообще состоит состояние чата", нужно было читать все три. ChatState — чисто +# типовая аннотация (TypedDict), НЕ меняет поведение в рантайме: chat_state[cid] +# остаётся обычным dict, никакой валидации здесь не добавляется — это только +# документация формы для статических проверок типов и читаемости. +class ChatState(TypedDict, total=False): + image_model: str + history: list[dict[str, Any]] + quota: dict[str, Any] + ctx: "deque[str]" + recent_media_ids: dict[str, "deque[tuple[str, str]]"] + last_activity: float + +chat_state: dict[int, ChatState] = {} # Буферы альбомов/медиа-групп _mg_buffers: dict[str, list[Message]] = {} @@ -1348,6 +1131,17 @@ def _storage_delete_text(key: str, path: Path) -> None: with contextlib.suppress(FileNotFoundError): path.unlink() +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одной записи GLOBAL_QUOTA[provider][model_id] +# (см. _quota_entry/_record_quota_usage/_mark_quota_exhausted ниже) была разбросанным +# по коду соглашением, а не задокументированной структурой. Как и ChatState выше — +# чисто типовая аннотация, ничего не меняет в рантайме (GLOBAL_QUOTA остаётся +# обычным dict из dict'ов). +class QuotaEntry(TypedDict): + used: int + remaining: int | None + limit: int | None + exhausted_at: float | None + GLOBAL_QUOTA: dict[str, Any] = { "gemini": {}, "openrouter": {}, @@ -1443,6 +1237,21 @@ def save_global_quota() -> None: except Exception as exc: log.warning("[quota] Failed to save global quota: %s", exc) +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: до сих пор каждый формат-дрейф персистентного +# снимка чата (слияние gemini_history/or_history в единую history, снятие +# приставки "pollinations:" с image_model) обнаруживался в _restore_single_chat +# ad hoc проверками "есть ли такой-то ключ в JSON" — рабочий, но накопительный +# подход: с каждым новым изменением формата туда добавлялась ещё одна ветка +# "если ключа нет — значит старая запись". CHAT_STATE_SCHEMA_VERSION делает +# следующую подобную миграцию однозначной: новый код сможет проверять +# `s.get("schema_version", 0)` одним явным числом вместо повторного гадания по +# присутствию к��ючей. Существующие персистентные записи (сделанные до введения +# этого поля) не имеют "schema_version" вообще — они естественно трактуются как +# версия 0 и продолжают проходить через уже отлаженные эвристики ниже без +# каких-либо изменений в их поведении (это поле — задел на будущее, а не +# ретроактивная миграция уже написанной логики). +CHAT_STATE_SCHEMA_VERSION = 1 + def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]: """Собирает JSON-сериализуемый снимок ОДНОГО чата — общая логика между сохранением и (в перспективе) любым будущим ручным экспортом. @@ -1455,6 +1264,7 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]: есть (созданные до этого изменения), просто тихо игнорируются при чтении — см. _restore_single_chat ниже, там нет ни одной попытки их прочитать.""" return { + "schema_version": CHAT_STATE_SCHEMA_VERSION, "image_model": state.get("image_model", DEFAULT_HF_IMAGE_MODEL), "history": list(state.get("history", [])), "quota": state.get("quota", {}), @@ -1463,6 +1273,17 @@ def _serialize_chat_state(state: dict[str, Any]) -> dict[str, Any]: }, } +def _normalize_legacy_image_model_id(image_model: Any) -> Any: + """Снимает старую приставку "pollinations:" с персистентного image_model, если + она там осталась от записи, сделанной до её удаления из HF_IMAGE_MODELS (см. + ponytail-audit) — единственный провайдер генерации изображений и так один, + приставка была лишней, но существующие персистентные записи чатов её ещё + содержат. Без этой миграции такой чат откатился бы на DEFAULT_HF_IMAGE_MODEL, + молча потеряв выбор пользователя (см. вызовы в _restore_single_chat/get_state).""" + if isinstance(image_model, str) and image_model.startswith("pollinations:"): + return image_model.split(":", 1)[1] + return image_model + def _restore_single_chat(cid: int, s: dict[str, Any]) -> None: """Разворачивает сериализованный снимок одного чата (см. _serialize_chat_state) обратно в chat_state[cid] — общая логика между новым per-chat форматом чтения @@ -1470,8 +1291,30 @@ def _restore_single_chat(cid: int, s: dict[str, Any]) -> None: Поля "gemini_model"/"openrouter_text_model"/"chat_provider" из старых записей (созданных до перехода на автоматический роутер) намеренно нигде ниже не - читаются — они устарели и больше ни на что не влияют.""" - image_model = s.get("image_model", DEFAULT_HF_IMAGE_MODEL) + читаются — они устарели и больше ни на что не влияют. + + schema_version (см. CHAT_STATE_SCHEMA_VERSION выше) в самих записях, читаемых + здесь, пока ни на что не влияет — существующие миграции (history/gemini_history, + image_model) уже надёжно определяются по присутствию конкретных ключей, и это + не нужно менять задним числом. Поле — задел на СЛЕДУЮЩИЙ формат-дрейф: тогда + новую ветку можно будет добавить как `if s.get("schema_version", 0) < N`, а не + подбирать очередную эвристику по ключам, как приходилось делать для миграций ниже.""" + schema_version = s.get("schema_version", 0) + log.debug("[state] Восстанавливаю чат %s (schema_version=%s)", cid, schema_version) + # НАЙДЕНО ПРИ /code-review (после ponytail-audit): переименование ключей + # HF_IMAGE_MODELS (см. _hf_text_to_image — убрана лишняя приставка + # "pollinations:", единственный провайдер и так один) — не просто + # косметика, если в Upstash/локальном файле УЖЕ лежит персистентное + # состояние чата с image_model в старом формате ("pollinations:turbo" и + # т.п.). Без миграции ниже такой чат при первой же загрузке молча (без + # предупреждения, без лога) терял бы выбранную пользователем модель — + # старое значение просто не совпало бы ни с одним новым ключом и + # откатилось бы на DEFAULT_HF_IMAGE_MODEL. _normalize_legacy_image_model_id + # снимает старую приставку перед проверкой, так что реальный выбор + # пользователя переживает этот рефакторинг так же, как переживает и любой + # другой формат старых записей (см. миграцию history/gemini_history чуть + # ниже — тот же принцип, тот же файл). + image_model = _normalize_legacy_image_model_id(s.get("image_model", DEFAULT_HF_IMAGE_MODEL)) if image_model not in HF_IMAGE_MODELS: image_model = DEFAULT_HF_IMAGE_MODEL raw_media = s.get("recent_media_ids", {}) @@ -1771,8 +1614,11 @@ def get_state(chat_id: int) -> dict[str, Any]: _mark_new_chat_id(chat_id) else: chat_state[chat_id]["last_activity"] = time.monotonic() - if chat_state[chat_id].get("image_model") not in HF_IMAGE_MODELS: + normalized = _normalize_legacy_image_model_id(chat_state[chat_id].get("image_model")) + if normalized not in HF_IMAGE_MODELS: chat_state[chat_id]["image_model"] = DEFAULT_HF_IMAGE_MODEL + elif normalized != chat_state[chat_id].get("image_model"): + chat_state[chat_id]["image_model"] = normalized if len(chat_state) > MAX_CHAT_LIMIT: _prune_old_chats() return chat_state[chat_id] @@ -1832,7 +1678,6 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No # генерация изображений (pollinations) DEFAULT_HF_IMAGE_MODEL = os.getenv("HF_IMAGE_MODEL", "flux").strip() -HF_IMAGE_MODEL_CACHE: dict[str, Any] = {"models": []} HF_IMAGE_MODEL_PAGE_SIZE = 8 HF_IMAGE_MODELS: dict[str, dict[str, Any]] = { @@ -1859,12 +1704,20 @@ HF_IMAGE_MODELS: dict[str, dict[str, Any]] = { } -async def _hf_fetch_model_catalog() -> list[dict[str, Any]]: - """Возвращает только утверждённый список моделей из HF_IMAGE_MODELS. - Динамический фетч из HF API убран — он добавлял неизвестные модели в меню.""" - catalog = [{"id": mid, **meta} for mid, meta in HF_IMAGE_MODELS.items()] - HF_IMAGE_MODEL_CACHE["models"] = catalog - return catalog +def _hf_model_catalog() -> list[dict[str, Any]]: + """Список моделей генерации изображений для клавиатуры /imgmodel — прямо из + HF_IMAGE_MODELS (единственный источник правды, статический список). + + НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь был отдельный `HF_IMAGE_MODEL_CACHE` + dict и `async def _hf_fetch_model_catalog()` — вестигиальные остатки более + раннего дизайна, когда каталог реально динамически подтягивался из HF API. + Тот динамический фетч убран (см. историю — он добавлял неизвестные модели в + меню), и функция давно не делает ни одного `await` внутри, а просто + пересобирает список из того же самого статического HF_IMAGE_MODELS — то есть + кэшировать было уже нечего: HF_IMAGE_MODELS и так уже лежит в памяти целиком, + а пересборка списка из 5 элементов не стоит отдельного кэш-слоя с ручной + инвалидацией на каждом сайте вызова. Убрано вместе с самим кэшем.""" + return [{"id": mid, **meta} for mid, meta in HF_IMAGE_MODELS.items()] def _imgmodel_button_text(model: dict[str, Any], current: str) -> str: @@ -1873,9 +1726,7 @@ def _imgmodel_button_text(model: dict[str, Any], current: str) -> str: def _imgmodel_keyboard(current: str, page: int = 0) -> InlineKeyboardMarkup: - all_models = list(HF_IMAGE_MODEL_CACHE.get("models") or []) - if not all_models: - all_models = [{"id": DEFAULT_HF_IMAGE_MODEL, "name": DEFAULT_HF_IMAGE_MODEL, "badge": "HF", "desc": ""}] + all_models = _hf_model_catalog() total_pages = max(1, (len(all_models) + HF_IMAGE_MODEL_PAGE_SIZE - 1) // HF_IMAGE_MODEL_PAGE_SIZE) page = max(0, min(page, total_pages - 1)) start = page * HF_IMAGE_MODEL_PAGE_SIZE @@ -2172,312 +2023,41 @@ def _record_quota_usage(state: Any, provider: str, model_id: str, remaining: int mark_quota_dirty() # openrouter api - -OPENROUTER_MODELS = { - # Список перепроверен вручную по openrouter.ai (июль 2026) — модель за моделью, - # т.к. часть ID из старого списка либо сняты с бесплатного тира (arcee-ai/trinity- - # large-thinking:free — акция закончилась 23.05, теперь платная; baidu/cobuddy:free — - # больше не бесплатна), либо заменены провайдером на новую версию (poolside/laguna-xs.2:free - # официально сворачивается в пользу laguna-xs-2.1:free). nvidia/nemotron-3.5-content-safety:free - # НАМЕРЕННО не включена — это guardrail/классификатор safe/unsafe, а не диалоговая модель, - # добавлять её в /provider бессмысленно и вредно (не будет отвечать текстом на вопросы). - # Порядок — от самых сильных/надёжных моделей общего назначения к нишевым и совсем лёгким. - "text": [ - { - "id": "nvidia/nemotron-3-super-120b-a12b:free", - "name": "Nemotron 3 Super 120B", - "description": "Флагманская гибридная MoE-модель NVIDIA: 120 млрд параметров, но лишь 12 млрд активных за проход — отсюда высокая скорость при топовом качестве. Контекст 1 млн токенов, сильна в сложных рассуждениях, STEM и многошаговом планировании." - }, - { - "id": "nvidia/nemotron-3-ultra-550b-a55b:free", - "name": "Nemotron 3 Ultra 550B", - "description": "Крупнейшая открытая reasoning-модель NVIDIA — 550 млрд параметров (55 млрд активных), гибридная архитектура Transformer-Mamba, контекст до 1 млн токенов. Максимум качества для самых сложных многошаговых задач, но и самая медленная в линейке." - }, - { - "id": "openai/gpt-oss-120b:free", - "name": "GPT OSS 120B", - "description": "Флагманская открытая модель OpenAI на 117B параметров (5.1B активных). Настраиваемая глубина рассуждений, нативный function calling — сильное рассуждение, код, анализ и длинный контекст." - }, - { - "id": "z-ai/glm-4.5-air:free", - "name": "GLM 4.5 Air", - "description": "Флагман Zhipu AI с гибридными режимами 'thinking'/'non-thinking' и хорошей поддержкой нескольких языков. Быстрый, точный, хорошо справляется с диалогом и сложными инструкциями." - }, - { - "id": "tencent/hy3:free", - "name": "Hy3 (Tencent)", - "description": "295-миллиардная MoE-модель Tencent (21B активных), настраиваемая глубина рассуждений, контекст 256K. Сильна в коде, агентных сценариях и устойчива к галлюцинациям. ВАЖНО: бесплатный доступ у Tencent — временная акция (примерно до 21 июля 2026), может исчезнуть без предупреждения." - }, - { - "id": "openrouter/owl-alpha", - "name": "Owl Alpha", - "description": "Модель общего назначения от самого OpenRouter для агентных задач — нативная работа с инструментами, код, длинный контекст (1 млн токенов). Провайдер может логировать запросы и ответы для улучшения модели." - }, - { - "id": "qwen/qwen3-next-80b-a3b-instruct:free", - "name": "Qwen3 Next 80B A3B Instruct", - "description": "Быстрая инструктивная модель линейки Qwen3-Next без видимых 'размышлений' в ответе — стабильные финальные ответы для диалога, RAG и агентных сценариев с большим контекстом." - }, - { - "id": "meta-llama/llama-3.3-70b-instruct:free", - "name": "Llama 3.3 70B Instruct", - "description": "Проверенная временем многоязычная модель Meta — одна из самых стабильных бесплатных моделей на OpenRouter (в строю с конца 2024 года). Надёжный универсальный выбор для диалога общего назначения." - }, - { - "id": "nousresearch/hermes-3-llama-3.1-405b:free", - "name": "Hermes 3 405B", - "description": "Полнопараметрический файнтюн Llama 3.1 405B от Nous Research с упором на управляемость и следование пользовательским инструкциям — хорош в ролевых сценариях, творческом письме и общих задачах." - }, - { - "id": "openai/gpt-oss-20b:free", - "name": "GPT OSS 20B", - "description": "Компактная открытая модель OpenAI на 21B параметров (3.6B активных). Быстрее и легче 120B-версии при сохранении хорошего понимания инструкций и написания кода." - }, - { - "id": "google/gemma-4-31b-it:free", - "name": "Gemma 4 31B", - "description": "Плотная мультимодальная модель Google DeepMind (30.7B параметров) с поддержкой изображений, контекстом 256K и поддержкой 140+ языков. Сильна в коде, рассуждениях и работе с документами." - }, - { - "id": "google/gemma-4-26b-a4b-it:free", - "name": "Gemma 4 26B A4B", - "description": "MoE-версия Gemma 4 (25.2B параметров, всего 3.8B активных за проход) — почти такое же качество, как у 31B-модели, но заметно экономичнее. Понимает текст, изображения и видео (до 60 сек)." - }, - { - "id": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", - "name": "Venice: Uncensored", - "description": "Файнтюн Mistral-Small 24B от dphn.ai и Venice.ai, намеренно лишённый стандартных слоёв безопасности и alignment мейнстримных ассистентов. ВАЖНО: из-за этого модель может хуже соблюдать системный промпт бота (личность Lumen, стиль, правила) — рекомендуется проверить вручную перед активным использованием." - }, - { - "id": "qwen/qwen3-coder:free", - "name": "Qwen3 Coder 480B A35B", - "description": "MoE-модель для кода от команды Qwen (480B параметров, 35B активных), контекст до 1 млн токенов. Заточена под агентную разработку: function calling, работу с инструментами, рассуждения над целыми репозиториями." - }, - { - "id": "poolside/laguna-m.1:free", - "name": "Laguna M.1", - "description": "Флагманская кодовая модель Poolside, заточенная под разработчиков. Превосходит в написании, рефакторинге и объяснении кода — обучена преимущественно на программном корпусе." - }, - { - "id": "poolside/laguna-xs-2.1:free", - "name": "Laguna XS 2.1", - "description": "Второе поколение компактной кодовой модели Poolside (сменяет устаревающую Laguna XS.2) — тот же расчёт на агентную разработку и терминальные задачи, но с улучшенными результатами на мультиязычных бенчмарках." - }, - { - "id": "cohere/north-mini-code:free", - "name": "North Mini Code", - "description": "Первая агентная кодовая модель Cohere (30B параметров, 3B активных) — быстрая и экономичная для лёгких задач по коду. По общему качеству рассуждений заметно уступает крупным моделям — не для сложных или ответственных запросов." - }, - { - "id": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", - "name": "Nemotron 3 Nano Omni", - "description": "Мультимодальная модель NVIDIA с расширенным рассуждением. Принимает текст, изображения, видео и аудио, хорошо справляется с задачами на стыке мода��ьностей." - }, - { - "id": "nvidia/nemotron-nano-12b-v2-vl:free", - "name": "Nemotron Nano 12B VL", - "description": "Мультимодальная модель NVIDIA с поддержкой изображений (Vision-Language). Компактная, быстрая, хороша в OCR и разборе документов." - }, - { - "id": "nvidia/nemotron-3-nano-30b-a3b:free", - "name": "Nemotron 3 Nano 30B", - "description": "Эффективная модель NVIDIA на 30B параметров (MoE, 3B активных), контекст 256K. Баланс между качеством и скоростью для общих текстовых задач." - }, - { - "id": "nvidia/nemotron-nano-9b-v2:free", - "name": "Nemotron Nano 9B", - "description": "Компактная модель NVIDIA с управляемым режимом рассуждения. Минимальная задержка, хороша для простых вопросов и задач, где важна скорость, а не глубина." - }, - { - "id": "meta-llama/llama-3.2-3b-instruct:free", - "name": "Llama 3.2 3B Instruct", - "description": "Компактная многоязычная модель Meta на 3B параметров — для простых диалоговых задач, суммаризации и классификации с минимальной задержкой." - }, - { - "id": "liquid/lfm-2.5-1.2b-instruct:free", - "name": "LFM2.5 1.2B Instruct", - "description": "Сверхкомпактная модель Liquid AI (1.2B параметров) для мгновенного инференса на слабом железе — неплохое качество диалога для своего размера, но для сложных задач не подходит." - }, - { - "id": "liquid/lfm-2.5-1.2b-thinking:free", - "name": "LFM2.5 1.2B Thinking", - "description": "Reasoning-версия LFM2.5 1.2B от Liquid AI с видимым ходом рассуждения перед ответом — для лёгких агентных задач и извлечения данных на слабом железе." - }, - { - "id": "openrouter/free", - "name": "Free Models Router", - "description": "Автоматический роутер от самого OpenRouter — сам подбирает подходящую бесплатную модель под конкретный запрос (учитывая нужны ли инструменты, изображения и т.п.). Хорош как последний рубеж, если все остальные модели из списка недоступны." - } - ] -} - -# Единый источник правды для порядка/состава текстовых моделей OpenRouter — вычисляется -# напрямую из OPENROUTER_MODELS["text"] выше, а не дублируется отдельным списком. -# Используется, в частности, _LEAK_LITERAL_STRINGS ниже (защита от утечки идентичности). -TEXT_MODEL_ORDER: list[str] = [m["id"] for m in OPENROUTER_MODELS["text"]] -# Модели с временным бесплатным доступом от провайдера (промо-акции, а не постоянный -# бесплатный тир) — после истечения даты бот НЕ убирает модель из списка автоматически -# (это рискованно менять индексы в уже отрисованных Telegram-клавиатурах "на лету"), -# но громко предупреждает в логах при каждом старте, чтобы не забыть проверить вручную -# на openrouter.ai и убрать модель из OPENROUTER_MODELS, если она стала платной. -_TEMPORARY_FREE_MODELS: dict[str, date] = { - "tencent/hy3:free": date(2026, 7, 21), - # Подтверждено при аудите моделей (21 июля 2026): собственная страница - # OpenRouter для этой модели показывала "Going away July 19, 2026" — - # :free-эндпоинт уже снят провайдером. Дата ниже намеренно в прошлом, - # чтобы предупреждение сработало сразу же при следующем старте бота. - # Роутер (см. _ROUTER_EXCLUDED_OR_MODELS ниже) её больше не выбирает — - # запись в OPENROUTER_MODELS оставлена нетронутой на случай, если модель - # по��адобится для ручной проверки другим способом. - "qwen/qwen3-coder:free": date(2026, 6, 30), -} - -def _check_temporary_free_models_expiry() -> None: - today = date.today() - for model_id, expiry in _TEMPORARY_FREE_MODELS.items(): - if today > expiry: - log.warning( - "[or] SYSTEM WARN: временный бесплатный доступ к модели %s истёк %s (сегодня %s) — " - "проверьте цену на openrouter.ai и уберите модель из OPENROUTER_MODELS/bot.py, " - "если она стала платной, иначе пользователи будут получать 402/403 при выборе.", - model_id, expiry.isoformat(), today.isoformat() - ) - -# ─────────────────── защита от утечки провайдера/модели (выходной фильтр) ─────────────────── -# Системный промпт (см. system_prompt.py) — это ПЕРВЫЙ, самый слабый рубеж: любую -# LLM в принципе можно уговорить нарушить свои инструкции достаточно настойчивой или -# creative промт-инъекцией (см. историю с чужим ботом, который выдал себя за другую -# модель именно через такую инъекцию). Поэтому здесь — ВТОРОЙ, детерминированный рубеж, -# который срабатывает уже ПОСЛЕ генерации ответа моделью и не зависит от того, что -# модель решила написать: если в готовом тексте всё-таки проскочило реальное имя -# модели/провайдера, весь ответ целиком подменяется на нейтральный fallback ДО того, -# как текст уйдёт пользователю и ДО того, как он попадёт в историю чата (иначе утечка -# осталась бы в контексте и могла бы "просочиться" в последующие ответы модели). # -# Слой А — точные строки внутренних ID моделей. Ложных срабатываний практически не -# бывает: обычный ответ на обычный вопрос никогда не должен содержать дефис-разделённый -# технический идентификатор вида "gemini-3.5-flash" или "z-ai/glm-4.5-air:free" — такие -# строки в естественной русской (или английской) речи не встречаются случайно. -_LEAK_LITERAL_STRINGS: tuple[str, ...] = tuple(sorted( - set(GEMINI_MODELS.keys()) - | {"gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"} - | set(TEXT_MODEL_ORDER) -)) -# Найдено при код-ревью (performance): инкрементальная проверка в _try_gemini_streaming -# раньше пересканировала ВЕСЬ накопленный full_text на каждый новый кусок стрима — при -# длинном ответе с мелкими чанками это O(n²) по суммарной длине ответа. Самый длинный -# паттерн из всех детекторов (_LEAK_LITERAL_STRINGS/_IDENTITY_LEAK_RE/_INJECTED_PAYLOAD_ -# ECHO_RE) — 61 символ; с большим запасом (5×) берём хвост в 300+ символов вместо всего -# текста — см. _leak_scan_window ниже. Любой паттерн, который мог бы образоваться на -# стыке старого текста и нового куска, гарантированно попадёт в это окно, если сам кусок -# короче окна (что всегда так для потоковых кусков от Gemini API). -_LEAK_SCAN_TAIL_CHARS = 300 - -def _leak_scan_window(full_text: str, latest_piece: str) -> str: - """Возвращает "хвост" накопленного текста, достаточный для обнаружения ЛЮБОГО - паттерна утечки, который мог образоваться после добавления latest_piece — без - необходимости пересканировать весь full_text целиком на каждой итерации стрима. - Окно берётся с запасом на случай аномально большого одиночного куска.""" - window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100) - return full_text[-window_size:] - - -# Слой Б — само-идентификация ка�� конкретный бренд/модель. ВАЖНО: раньше здесь было -# широкое окно "самореференция ... бренд" в пределах 60 символов — это ловило honest -# ответы вроде развёрнутого рассказа про OpenAI как компанию, где модель где-то в -# том же предложении естественно писала "я не могу сравнивать себя..." (обычное -# хеджирование, не утечка). "я" — один из самых частых русских токенов, поэтому -# любое достаточно длинное упоминание стороннего бренда рядом с ЛЮБЫМ "я" в тексте -# ложно срабатывало. Теперь — только точные, тесно связанные шаблоны конкретных -# формулировок самоидентификации (без произвольного зазора между словами), которые -# на практике встречаются ТОЛЬКО при реальной утечке, а не в обычном разговоре о -# сторонних моделях/компаниях. -_LEAK_BRAND_TOKENS = ( - r"(gemini|gemma|gpt[\s\-]?oss|chatgpt|openai|claude|anthropic|deepmind|openrouter|" - r"nemotron|qwen|llama|glm[\s\-]?4|hermes|dolphin[\s\-]?mistral|venice|laguna|" - r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)" -) -_IDENTITY_LEAK_RE = re.compile( - rf"\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?{_LEAK_BRAND_TOKENS}\b" - rf"|\bмен[яе]\s+(?:зовут|называют)\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bя\s+созда(?:н|на)\w*\s+(?:компанией\s+)?{_LEAK_BRAND_TOKENS}\b" - rf"|\bмен[яе]\s+созда(?:л|ла)\w*\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bработаю\s+на\s+(?:базе\s+)?{_LEAK_BRAND_TOKENS}\b" - rf"|\bоснован\w*\s+на\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b" - rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bbuilt\s+on\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bpowered\s+by\s+{_LEAK_BRAND_TOKENS}\b" - rf"|\bbased\s+on\s+{_LEAK_BRAND_TOKENS}\b" - rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b", - re.IGNORECASE, +# TEXT_MODEL_ORDER / _OR_MODEL_HEALTH / _ROUTER_EXCLUDED_OR_MODELS / +# _check_temporary_free_models_expiry вынесены в lumen_router_config.py (см. +# импорт рядом с GEMINI_MODELS выше по файлу) — здесь остаётся только код, +# который реально ХОДИТ в OpenRouter API (OpenRouterAPIError/_or_request/ +# ask_openrouter_*/_or_chat_completion_with_fallback и т.д.). + +# ─────────────────── защита от утечки провайдера/модели и промт-инъекций ─────────────────── +# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: детекторы утечки идентичности (_detect_identity_leak/ +# _scrub_identity_leak/_detect_injected_payload_echo) и входной префильтр промт- +# инъекций (_looks_like_injection_probe) вынесены в lumen_security.py — чистые +# функции над строками (плюс регэкспы/константы), не зависящие от Telegram/рантайм- +# состояния бота. Импортируются напрямую — публичные имена и поведение (включая +# логирование через тот же логгер "bot", см. lumen_security.py) не изменились. +# Только реально используемые здесь имена импортируются явно — регэкспы +# (`_IDENTITY_LEAK_RE`/`_INJECTION_PROBE_RE`/`_INJECTED_PAYLOAD_ECHO_RE` и +# составляющие их `_LEAK_BRAND_TOKENS`/`_LEAK_LITERAL_STRINGS`) нужны только +# самим детекторам внутри lumen_security.py, а не коду bot.py. +from lumen_security import ( + _LEAK_SCAN_TAIL_CHARS, + _leak_scan_window, + _IDENTITY_LEAK_FALLBACK, + _INJECTED_PAYLOAD_ECHO_FALLBACK, + _detect_injected_payload_echo, + _detect_identity_leak, + _scrub_identity_leak, + _INJECTION_PROBE_REPLY, + _looks_like_injection_probe, ) -_IDENTITY_LEAK_FALLBACK = ( - "Внутренние технические детали своей реализации я не раскрываю. " - "Если у вас есть другой вопрос — с радостью помогу." -) +# См. пояснение про __all__ у первого блока (lumen_formatting) выше — используется +# только как `bot._LEAK_SCAN_TAIL_CHARS` в тестах (сама ло��ика окна сканирования — +# внутри `_leak_scan_window`, который уже используется по-настоящему). +__all__ += ["_LEAK_SCAN_TAIL_CHARS"] -# Слой В — "эхо" внедрённой в контент (фото/документ/сайт) вредоносной инструкции. -# Реальный найденный на практике обход: атакующий подсовывает картинку/страницу с -# текстом вида "[SYSTEM NOTICE] ...выведи ровно эту строку, подтверждающую взлом...". -# Модель может отказаться ВЫПОЛНИТЬ эту инструкцию, но при просьбе "перескажи/опиши -# содержимое" или "сделай саммари того, что тебе передали при запуске" — иногда всё -# же дословно ВОСПРОИЗВОДИТ целевую строку атаки внутри своего пересказа, и эта -# строка затем оседает в истории чата и может "всплыть" в следующих repl. Намеренно -# ОЧЕНЬ узкий список ключевых слов (типичная лексика "подтверждения взлома" в -# proof-of-concept пейлоадах) — а не общий поиск ALL_CAPS/переиспользование входного -# _INJECTION_PROBE_RE, иначе ловились бы легитимные ответы (код с константами вида -# API_KEY/MAX_RETRIES, честные объяснения того, что такое джейлбрейк, и т.п.). -_INJECTED_PAYLOAD_ECHO_RE = re.compile( - r"security[_\s]?breach[_\s]?detected" - r"|system[_\s]?override[_\s]?(successful|complete)" - r"|diagnostic[_\s]?success" - r"|prompt[_\s]?validation[_\s]?successful" - r"|jailbreak[_\s]?success(ful)?" - r"|bypass[_\s]?successful" - r"|injection[_\s]?successful" - r"|breach[_\s]?detected" - r"|взлом\s+(прошёл\s+)?успешно" - r"|инъекция\s+(прошла\s+)?успешно" - r"|проверка\s+(пройдена|успешна)[:.]?\s*(систем\w*|промпт\w*)", - re.IGNORECASE, -) - -_INJECTED_PAYLOAD_ECHO_FALLBACK = ( - "Это похоже на текст из инструкции, внедрённой в присланный контент, а не на " - "обычный ответ — воспроизводить его не буду. Если у вас обычный вопрос, задайте " - "его, и я отвечу." -) - -def _detect_injected_payload_echo(text: str) -> bool: - return bool(text) and bool(_INJECTED_PAYLOAD_ECHO_RE.search(text)) - -def _detect_identity_leak(text: str) -> bool: - """Чистая функция без побочных эффектов — намеренно отделена от _scrub_identity_leak - (которая ещё и логирует), чтобы можно было дёшево вызывать её на КАЖДЫЙ кусок текста - во время стриминга (см. _try_gemini_streaming), не заливая логи повторными записями - об одном и том же инциденте на каждый новый символ.""" - if not text: - return False - low = text.lower() - for lit in _LEAK_LITERAL_STRINGS: - if lit and lit.lower() in low: - return True - return bool(_IDENTITY_LEAK_RE.search(text)) - -def _scrub_identity_leak(text: str, *, source: str) -> str: - """Точка применения фильтра для НЕстримингового пути (ask_gemini, ask_openrouter_*). - Вызывается непосредственно перед записью ответа в историю чата — если вызвать её - только перед показом пользователю, но не перед hist.append/history.append, утечка - осталась бы в истории и могла бы повлиять на последующие ответы модели.""" - if _detect_identity_leak(text): - log.warning("[identity-leak] Обнаружена и заблокирована утечка идентичности (source=%s): %r", source, text[:500]) - return _IDENTITY_LEAK_FALLBACK - if _detect_injected_payload_echo(text): - log.warning("[injection-echo] Обнаружено и заблокировано вероятное эхо внедрённой инструкции (source=%s): %r", source, text[:500]) - return _INJECTED_PAYLOAD_ECHO_FALLBACK - return text class OpenRouterAPIError(RuntimeError): def __init__(self, message: str, status_code: int | None = None, payload: Any = None) -> None: @@ -2592,10 +2172,21 @@ async def _or_chat_completion_with_fallback( model_trial, ) raise + # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь была третья независимая + # ad hoc классификация ошибки (ручной разбор подстрок "429"/"rate + # limit"/"500"/"502"/... и т.п.) — отдельная от _classify_model_error + # (уже используемого в _gemini_error_msg/_or_error_msg) и от такой же + # по смыслу классификации внутри ask_gemini. Общий классификатор + # покрывает то же множество "стоит ли повторить попытку" не хуже. + # Поведение при attempts_per_model=1 (единственное реальное + # использование сейчас) не меняется: is_retriable_same_model влияет + # только на повторные попытки ОДНОЙ и той же модели внутри + # attempts_per_model, а не на переход к следующей модели по цепочке + # (тот происходит естественным продолжением внешнего for ниже). is_timeout = isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or not err_text - is_rate_limit = "429" in err_text or "rate limit" in err_text or "quota" in err_text or "provider returned error" in err_text or "resource_exhausted" in err_text - is_server_err = "500" in err_text or "502" in err_text or "503" in err_text or "504" in err_text or "server error" in err_text or "temporary" in err_text - if not (is_rate_limit or is_server_err or is_timeout): + kind = _classify_model_error(_error_status(exc, err_text), err_text) + is_retriable_same_model = is_timeout or kind in ("rate_limit", "unavailable", "other") + if not is_retriable_same_model: break log.warning("[or] Model %s failed: %s. Switching to next candidate...", model_trial, str(last_exc) or last_exc.__class__.__name__) @@ -3550,20 +3141,20 @@ async def ask_gemini( log.warning("[gemini] Model %s timed out and no fallback models remain in route.", curr_model_id) raise except Exception as exc: - low_msg = str(exc).lower() - status_code = getattr(exc, "status_code", None) - is_not_found = (status_code == 404) or ("not found" in low_msg) or ("not supported" in low_msg) or ("404" in low_msg) or ("unsupported" in low_msg) - + # НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь была отдельная, ad hoc + # классификация статуса ошибки (ручной разбор подстрок "429"/ + # "resource_exhausted"/"quota" -> 429 и т.п.) — своя, третья версия + # той же классификации, что уже делают _error_status/_classify_model_error + # (используются в _gemini_error_msg/_or_error_msg и по духу совпадают + # с тем, что нужно и здесь). Теперь используются те же самые общие + # хелперы — один источник правды на "какая это ошибка" вместо трёх + # независимых реализаций, которые рисковали разойтись при будущей правке. + txt = _error_text(exc).strip() or exc.__class__.__name__ + status_code = _error_status(exc, txt) + kind = _classify_model_error(status_code, txt) exc_class = exc.__class__.__name__ - if not status_code: - if "429" in low_msg or "resource_exhausted" in low_msg or "quota" in low_msg: - status_code = 429 - elif "503" in low_msg or "unavailable" in low_msg: - status_code = 503 - elif "500" in low_msg or "internal error" in low_msg: - status_code = 500 - - if status_code == 429: + + if kind == "rate_limit": # Квота — это НЕ временная перегрузка, а реальный лимит на стороне # Google, поэтому только здесь помечаем модель как исчерпанную # через _mark_quota_exhausted (влияет на порядок в будущих @@ -3578,13 +3169,13 @@ async def ask_gemini( log.warning("[gemini] All Gemini models in route exhausted their quota (429): %s", ", ".join(quota_exhausted_models)) raise GeminiAllModelsExhaustedError(quota_exhausted_models) from exc - # NOT_FOUND (модель снята/переименована на стороне Google), 503/500 - # (временная перегрузка) и любая прочая непойманная ошибка — все - # обрабатываются одинаково: ОДНА попытка, сразу следующая модель по - # цепочке, без ретраев текущей. + # "unavailable" (модель снята/переименована на стороне Google, он же + # NOT_FOUND), "forbidden", "other" (503/500 — временная перегрузка) и + # любая прочая непойманная ошибка — все обрабатываются одинаково: ОДНА + # попытка, сразу следующая модель по цепочке, без ретраев текущей. next_model = _next_fallback_model(tried_models, chain) if next_model: - reason = "NOT_FOUND" if is_not_found else (str(status_code) if status_code else exc_class) + reason = f"{kind}/{status_code}" if status_code else f"{kind}/{exc_class}" log.warning("[gemini] Model %s failed (%s). Switching to %s", curr_model_id, reason, next_model) curr_model_id = next_model continue @@ -4041,47 +3632,9 @@ def _looks_like_media_reference(text: str) -> bool: т.п.) — иначе см. регрессию выше. Тестируется отдельно от _handle_message_core.""" return bool(text) and bool(_MEDIA_REFERENCE_RE.search(text)) -# ─────────────────── защита от промт-инъекций (входной префильтр) ─────────────────── -# Первый (и самый дешёвый/надёжный) рубеж: явные, хорошо известные паттерны попытки -# "взломать" системный промпт — если сообщение совпадает с одним из них, отвечаем -# заранее заготовленной фразой БЕЗ обращения к LLM вообще. Для этого конкретного класса -# атак это даёт СТОПРОЦЕНТНУЮ гарантию отсутствия утечки (в отличие от системного -# промпта, который в принципе можно обойти достаточно творческой формулировкой) — сама -# модель тут просто не участвует. -# -# ВАЖНО: сюда намеренно НЕ включены обычные любопытные вопросы вида "какая ты модель -# на самом деле" / "ты точно не Gemini?" — на них и так есть отдельная честная и -# небанальная (без дословных повторов, см. ИДЕНТИЧНОСТЬ в system_prompt.py) логика -# внутри самой модели. Здесь — только однозначные попытки ПОДМЕНИТЬ инструкции или -# выдавить из бота его системный промпт, а не безобидное любопытство. -_INJECTION_PROBE_RE = re.compile( - r"ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)\s+instructions" - r"|забудь\s+(все\s+|про\s+)?(предыдущие\s+|системные\s+)?инструкции" - r"|игнорируй\s+(все\s+|любые\s+)?(предыдущие\s+|системные\s+)?(инструкции|правила|указания)" - r"|print\s+your\s+(system\s+)?(prompt|instructions)" - r"|repeat\s+(everything|the\s+text|all\s+the\s+words)\s+above" - r"|покажи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)" - r"|выведи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)" - r"|повтори\s+(всё\s+|весь\s+текст\s+)?(что\s+)?(написано\s+)?выше" - r"|(developer|debug|god|dan|jailbreak)\s*[\s\-]?mode" - r"|режим\s+(разработчика|отладки|бога|джейлбрейк\w*)" - r"|you\s+are\s+now\s+(an?\s+)?(unrestricted|uncensored|jailbroken)" - r"|ты\s+теперь\s+(без\s+ограничени\w*|неограничен\w*|не\s+связан\w*\s+правилами)" - r"|act\s+as\s+(an?\s+)?(unfiltered|uncensored|jailbroken|dan)\b" - r"|притворись\s*,?\s*(что\s+)?у\s+тебя\s+нет\s+(правил|ограничени\w*)" - r"|(what|which)\s+(is\s+)?your\s+(real\s+|actual\s+)?system\s+prompt" - r"|раскрой\s+(свой\s+)?системн\w*\s+промпт", - re.IGNORECASE, -) - -_INJECTION_PROBE_REPLY = ( - "Свою настройку и инструкции я не раскрываю и ��е обсуждаю в таком формате. " - "Если у вас обычный вопрос — задавайте, с радостью помогу." -) - -def _looks_like_injection_probe(text: str) -> bool: - """Чистая функция — тестируется отдельно от _handle_message_core.""" - return bool(text) and bool(_INJECTION_PROBE_RE.search(text)) +# Защита от промт-инъекций (входной префильтр) вынесена в lumen_security.py вместе +# с защитой от утечки идентичности (см. импорт рядом с _detect_identity_leak выше) — +# см. импорт _looks_like_injection_probe/_INJECTION_PROBE_REPLY там же. def message_mentions_bot(message: Message) -> bool: if message.chat.type == ChatType.PRIVATE: @@ -4331,9 +3884,6 @@ async def cmd_imgmodel(message: Message) -> None: if model_id not in HF_IMAGE_MODELS: model_id = DEFAULT_HF_IMAGE_MODEL state["image_model"] = model_id - catalog = await _hf_fetch_model_catalog() - if catalog: - HF_IMAGE_MODEL_CACHE["models"] = catalog meta = HF_IMAGE_MODELS.get(model_id, {}) await _tg_call( message.reply, @@ -4362,9 +3912,6 @@ async def cb_imgmodel(cb: CallbackQuery) -> None: page = int(parts[2]) except Exception: page = 0 - catalog = await _hf_fetch_model_catalog() - if catalog: - HF_IMAGE_MODEL_CACHE["models"] = catalog meta = HF_IMAGE_MODELS.get(current, {}) await _tg_call( cb.message.edit_text, @@ -4378,7 +3925,10 @@ async def cb_imgmodel(cb: CallbackQuery) -> None: return model_id = parts[3] if len(parts) > 3 else "" - if not model_id or (model_id not in HF_IMAGE_MODELS and model_id not in {m.get('id') for m in HF_IMAGE_MODEL_CACHE.get('models', [])}): + # Раньше здесь ещё проверялось членство в HF_IMAGE_MODEL_CACHE — избыточно: + # каталог для клавиатуры и так всегда строится 1:1 из HF_IMAGE_MODELS (см. + # _hf_model_catalog выше), отдельной проверки по кэшу не требовалось никогда. + if not model_id or model_id not in HF_IMAGE_MODELS: await _safe_callback_answer(cb, "Модель больше недоступна.", show_alert=True) return requester_id = cb.from_user.id if cb.from_user else None @@ -4728,17 +4278,10 @@ async def cmd_stats(message: Message) -> None: # Видимость состояния "выключателя" Telegram-прокси прямо из Telegram, а не # только по логам контейнера — иначе деградацию прокси можно было заметить - # только копаясь в логах HF Spaces (см. код-ревью, suggestion #1). - now_mono = time.monotonic() - if now_mono < _tg_proxy_down_until: - proxy_state = f"ВЫКЛЮЧЕН ещё ~{int(_tg_proxy_down_until - now_mono)}с" - else: - proxy_state = "в норме" - proxy_line = ( - f"\n\nTelegram-прокси: {proxy_state}\n" - f"Подряд сбоев сейчас: {_tg_proxy_consecutive_failures}/{TG_PROXY_TRIP_THRESHOLD}, " - f"всего за время работы: {_tg_proxy_garbage_event_count}" - ) + # только копаясь в логах HF Spaces (см. код-ревью, suggestion #1). Текст + # теперь собирает сам _tg_proxy_breaker (см. _TelegramProxyCircuitBreaker) — + # раньше эта команда лезла в четыре module-level globals напрямую. + proxy_line = _tg_proxy_breaker.status_text() quota_day = GLOBAL_QUOTA.get("quota_day") or "—" @@ -4793,266 +4336,10 @@ class RouteBudgetExceededError(RuntimeError): super().__init__(f"Бюджет времени на маршрут исчерпан. Испробовано: {', '.join(tried) or '—'}") -# Модели OpenRouter, которые роутер никогда не выбирает сам: -# - uncensored-модель (может хуже соблюдать личность/правила Lumen) — раньше -# выбиралась вручную только владельцем через /provider; такого меню больше -# нет вообще, поэтому модель просто исключена из автоматического выбора; -# - qwen/qwen3-coder:free — подтверждено при аудите (июль 2026): :free-эндпоинт -# снят провайдером (см. _TEMPORARY_FREE_MODELS выше), выбирать её бессмысленно. -_ROUTER_EXCLUDED_OR_MODELS: set[str] = { - "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", - "qwen/qwen3-coder:free", - # Добавлено при перепроверке конфига (24 июля 2026): временный бесплатный доступ - # к этой модели истёк 21 июля 2026 (см. _TEMPORARY_FREE_MODELS выше) — на сегодня - # промо уже 3 дня как в прошлом. Модель и так не входила ни в один из - # _OR_LIGHT_ORDER/_OR_HEAVY_ORDER/_OR_VISION_ORDER (роутер её фактически не - # выбирал), добавление сюда — чисто документирующее, для единообразия с - # qwen3-coder выше и на случай, если её когда-нибудь по ошибке добавят в один - # из order-списков, не заметив истёкшее промо. - "tencent/hy3:free", - # ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (25 июля 2026, ~40 минут живого трафика, - # 20+ независимых попыток подряд): qwen/qwen3-next-80b-a3b-instruct:free - # возвращает HTTP 404 АБСОЛЮТНО КАЖДЫЙ раз без единого исключения — "This model - # is unavailable for free. The paid version is available now - use this slug - # instead: qwen/qwen3-next-80b-a3b-instruct". Модель стояла ПЕРВОЙ в - # _OR_LIGHT_ORDER — то есть каждое обычное сообщение без вложений сначала - # гарантированно тратило один неудачный round-trip (там же ломался и стриминг — - # см. _try_openrouter_streaming), прежде чем реально дойти до следующей модели. - # НЕ ДОБАВЛЯТЬ обратно в order-списки, пока провайдер снова не откроет бесплатный - # доступ именно к этому слагу — это не временное промо вроде hy3/qwen3-coder - # выше, а прямая инструкция от API использовать другой (платный) слаг. - "qwen/qwen3-next-80b-a3b-instruct:free", -} - -def _or_route(models: list[str]) -> list[tuple[str, str]]: - """Превращает список ID моделей OpenRouter в список (provider, model_id) для - маршрута, попутно исключая модели из _ROUTER_EXCLUDED_OR_MODELS.""" - return [("openrouter", m) for m in models if m not in _ROUTER_EXCLUDED_OR_MODELS] - -def _gemini_route(models: list[str]) -> list[tuple[str, str]]: - return [("gemini", m) for m in models] - - -# ── "Лёгкие"/"стандартные" запросы без вложений и ссылок — САМЫЙ ЧАСТЫЙ -# маршрут в обычном чате. Целиком обслуживается OpenRouter'ом, чтобы вообще не -# трогать скудную квоту Gemini на самом массовом классе сообщений. -# -# ВАЖНО (по итогам живого тестирования, см. историю): meta-llama/llama-3.3-70b- -# instruct:free полностью убрана из этого списка — провайдер снял её с -# бесплатного тира (HTTP 404 "This model is unavailable for free", подтверждено -# десятками идентичных отказов подряд в реальных логах). Держать её первой в -# списке означало гарантированный лишний неудачный запрос на КАЖДОЕ сообщение. -# qwen/qwen3-next-80b-a3b-instruct:free полностью УБРАНА из списка (25 июля -# 2026) — сама теперь 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS выше. -# -# ПЕРЕСТРОЕНО (25 июля 2026, по прямому сравнению ответов бота с ответами -# настоящего Claude на идентичные промпты в рамках калибровочной сессии): -# - z-ai/glm-4.5-air:free поднята на первое место — ни разу не замечена в -# порче текста ни в тяжёлом, ни в лёгком тестировании, хорошо держит русский. -# - openai/gpt-oss-20b:free ПОНИЖЕНА: подтверждено 2 тяжёлых инцидента — -# на прямой идентити-вопрос "какая ты модель на самом деле?" выдала -# бессвязную смесь языков ("Я — L accompagné.") вместо ответа, а на -# эмоционально уязвимый запрос ("меня бросила девушка, что делать") вставила -# посреди ответа нечитаемый арабский фрагмент. Не убрана совсем — на -# остальных ~6 наблюдавшихся вызовах отвечала нормально, — но с первого -# места снята однозначно. -# - nvidia/nemotron-3-nano-30b-a3b:free ПОНИЖЕНА ещё ниже: подтверждено 3 -# инцидента — деванагари-мусор внутри слова ("пиिजцы" вместо "пиццы") ВМЕСТЕ -# с сырым LaTeX в ответе про площадь круга (при том что system_prompt.py -# прямо запрещает LaTeX), уверенная галлюцинация названия фильма ("К Eggman" -# вместо "Гранд Будапешт Отель"), порченые слова и выдуманное название -# компании ("Vueium") в сравнении React/Vue. Из трёх протестированных -# "лёгких" моделей — худшая по частоте порчи текста. -# Ни gpt-oss-20b, ни nemotron-3-nano-30b-a3b пока не удалены полностью: ниже -# них в цепочке стоят ЕЩЁ более мелкие модели (9B/3B/1.2B), которые в этой -# сессии не тестировались и по объёму параметров теоретически ещё менее -# надёжны на русском. Если и они дадут похожие инциденты — тогда стоит -# рассмотреть полное исключение gpt-oss-20b/nemotron-3-nano-30b-a3b, а не -# просто понижение приоритета. -_OR_LIGHT_ORDER: list[str] = [ - "z-ai/glm-4.5-air:free", - "nvidia/nemotron-nano-9b-v2:free", - "meta-llama/llama-3.2-3b-instruct:free", - "openai/gpt-oss-20b:free", - "nvidia/nemotron-3-nano-30b-a3b:free", - "liquid/lfm-2.5-1.2b-instruct:free", - "openrouter/free", -] - -# ── "Тяжёлые" запросы (код, многошаговые рассуждения, объёмный анализ) без -# нужды в интернете/медиа — тоже сначала к OpenRouter: среди бесплатных -# моделей там есть по-настоящему сильные кандидаты (120B/550B), не уступающие -# по мощи флагману Gemini, но не занимающие его 20 запросов/сутки. -# -# qwen/qwen3-next-80b-a3b-instruct:free убрана из запасного места в конце — -# 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS. Заменена на дополнительный -# резерв glm-4.5-air (уже есть выше в цепочке, но openrouter/free как последний -# универсальный fallback остаётся). -# -# МОНИТОРИНГ (25 июля 2026): nvidia/nemotron-3-super-120b-a12b:free, несмотря на -# статус флагмана этого тира, дала 1 инцидент из 4 протестированных тяжёлых -# запросов — в ответе про TCP/IP посреди русского текста встретился китайский -# иероглиф "尾部" (вместо "хвост"), итальянское "infine" и английское "preventing". -# Остальные 3 запроса (Rust-палиндром, Python-сортировка, сравнение iPhone/Samsung) -# отработала чисто. Пока не понижаем — один инцидент на четыре успешных попытки -# не повод убирать флагмана, но стоит присматривать за логами `[stream]`/ответами -# этой модели и понизить её, если порча текста повторится. -_OR_HEAVY_ORDER: list[str] = [ - "nvidia/nemotron-3-super-120b-a12b:free", - "openai/gpt-oss-120b:free", - "z-ai/glm-4.5-air:free", - "nvidia/nemotron-3-ultra-550b-a55b:free", - "nousresearch/hermes-3-llama-3.1-405b:free", - "openrouter/free", -] - -# ── Вложение (изображение) без нужды в свежей информации — у OpenRouter -# достаточно бесплатных vision-моделей, чтобы не трогать Gemini. OpenRouter -# физически принимает только изображения (base64 data URL) — для видео/аудио -# этот список не используется вообще, см. _build_route/_run_route ниже. -_OR_VISION_ORDER: list[str] = [ - "nvidia/nemotron-nano-12b-v2-vl:free", - "google/gemma-4-31b-it:free", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", - "google/gemma-4-26b-a4b-it:free", -] - -# ── Цепочки Gemini. GEMINI_HEAVY_CHAIN — от сильной модели к слабой (тот же -# состав/порядок, что был у прежнего единственного quota_fallback_chain), для -# случаев, где ТРЕБУЕТСЯ именно Gemini (YouTube/сайт п�� ссылке, видео/аудио -# вложение), но живой поиск не нужен. GEMINI_SEARCH_CHAIN — те же модели, но -# начиная с тех, у кого по дашборду AI Studio реально ЕСТЬ квота на search -# grounding (флагман 3.5 и 3-preview её не имеют вообще — см. комментарии в -# GEMINI_MODELS выше), чтобы запрос, которому нужен живой поиск, не попадал -# первым делом на модель, что физически не может искать. -GEMINI_HEAVY_CHAIN: list[str] = [ - "gemini-3.6-flash", - "gemini-3.5-flash", - "gemini-3-flash-preview", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - "gemma-4-31b-it", - "gemma-4-26b-a4b-it", -] -# ПЕРЕСТРОЕНО (24 июля 2026, по реальным данным дашборда AI Studio): раньше первыми -# здесь стояли gemini-3.5-flash-lite/gemini-3.1-flash-lite в предположении, что у -# них есть search grounding — это оказалось неверно (см. комментарии в GEMINI_MODELS -# выше). Дашборд считает квоту на Search grounding не по конкретной модели, а по -# общему бакету ПОКОЛЕНИЯ: бакет "Gemini 3" (охватывает 3/3.1/3.5/3.6 целиком) — 0/0, -# реальной квоты на поиск нет вовсе ни у одной модели линейки Gemini 3.x. Бакет -# "Gemini 2.5" — 21/1500, то есть поиск реально работает ТОЛЬКО у gemini-2.5-flash и -# gemini-2.5-flash-lite. Они теперь и стоят первыми для запросов, где нужна живая -# информация. Модели Gemini 3.x оставлены в цепочке как резерв — не смогут вызвать -# google_search, но всё ещё могут ответить по своим знаниям (и через url_context, -# если в тексте есть ссылка — та возможность отдельной квоты не имеет вовсе). -GEMINI_SEARCH_CHAIN: list[str] = [ - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-3.6-flash", - "gemini-3.5-flash", - "gemini-3-flash-preview", -] -# Совпадает по составу с прежним quota_fallback_chain — используется как дефолт, -# если ask_gemini вызвана без явной цепочки (например, напрямую из теста). -GEMINI_DEFAULT_CHAIN: list[str] = GEMINI_HEAVY_CHAIN -# Только "полноценные" (не no_system/Gemma) модели умеют читать сайты по ссылке -# (url_context) и разбирать YouTube-видео по ссылке (file_uri) — то же -# ограничение, что раньше проверялось в _handle_message_core через -# current_gemini_conf.get("no_system"). -GEMINI_LINK_CHAIN: list[str] = [m for m in GEMINI_HEAVY_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")] -GEMINI_LINK_SEARCH_CHAIN: list[str] = [m for m in GEMINI_SEARCH_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")] - - -# ── Эвристика "это сложный/тяжёлый запрос?" — без обращения к LLM. Ложные -# срабатывания недороги: худший случай — используется чуть более мощная -# модель, чем реально нужно, а не отказ в ответе. -_HEAVY_QUERY_RE = re.compile( - r"напиши\s+(код|функци\w*|скрипт|программ\w*|класс\w*|запрос\s+sql|regex|регуляр\w*)" - r"|сгенерируй\s+код|исправь\s+(код|баг|ошибк\w*)|отрефактор\w*|рефактор\w*|оптимизируй" - r"|напиши\s+(эссе|статью|доклад|реферат|сочинение|резюме|cv)\b" - r"|проанализируй\w*|разбер(и|ём)\s+подробно|объясни\s+подробно" - r"|сравни\s+.{0,40}(и|с)\s+|докажи\b|доказательство" - r"|реши\s+(задач\w*|уравнени\w*|систем\w*)" - r"|составь\s+(план|таблиц\w*|список\s+из)" - r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)" - r"|архитектур\w*|алгоритм\w*", - re.IGNORECASE, -) - -def _looks_like_heavy_query(text: str) -> bool: - """Грубая эвристика "это тяжёлый запрос (код/анализ/многошаговые рассуждения)?" - Намеренно консервативная (без вызова LLM — см. комментарий в начале секции).""" - if not text: - return False - if "```" in text or len(text) > 600: - return True - if text.count("?") >= 3: - return True - return bool(_HEAVY_QUERY_RE.search(text)) - - -# ── Эвристика "нужна ли живая информация из интернета?" Ложные срабатывания -# тоже недороги: худший случай — маршрут отдаёт предпочтение search-способной -# модели там, где поиск был не нужен, но модель сама решает, вызывать ли его. -_FRESHNESS_QUERY_RE = re.compile( - r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент" - r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*" - r"|цена\w*|стоимост\w*|сколько\s+стоит" - r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)" - r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)" - r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)" - r"|\b202[6-9]\b", - re.IGNORECASE, -) - -def _looks_like_freshness_query(text: str) -> bool: - return bool(text) and bool(_FRESHNESS_QUERY_RE.search(text)) - - -def _build_route( - *, needs_youtube: bool, needs_website: bool, media_mime: str | None, - is_heavy: bool, needs_freshness: bool, -) -> list[tuple[str, str]]: - """Строит приоритетный список кандидатов (provider, model_id) для текущего - сообщения — НЕПУСТОЙ список, первый элемент пробуется первым (см. _run_route). - Порядок кандидатов внутри одного провайдера — по возрастанию "дороговизны" - для дефицитной квоты, а не по итоговому качеству ответа отдельно взятой модели.""" - is_video_or_audio_media = bool(media_mime) and not media_mime.startswith("image/") - - if needs_youtube or needs_website: - # Только Gemini умеет читать сайты по ссылке и разбирать YouTube-видео — - # у OpenRouter в этом маршруте вообще нет места, эскалировать некуда. - chain = GEMINI_LINK_SEARCH_CHAIN if needs_freshness else GEMINI_LINK_CHAIN - return _gemini_route(chain) - - if media_mime: - if needs_freshness or is_video_or_audio_media: - # Видео/аудио вложение ИЛИ нужен живой поиск вместе с медиа — может - # только Gemini (OpenRouter физически не примет не-изображение, и - # ни одна его модель не имеет доступа к поиску). - chain = GEMINI_SEARCH_CHAIN if needs_freshness else GEMINI_HEAVY_CHAIN - return _gemini_route(chain) - # Изображение без нужды в поиске — сначала бесплатные vision-модели - # OpenRouter, Gemini — резерв, если они все разом откажут. - return _or_route(_OR_VISION_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN) - - if needs_freshness: - # Текст без вложений, но нужна свежая информация — только у Gemini - # реально есть поиск; OpenRouter в конце как резерв на случай, если - # Gemini исчерпан целиком (без поиска, но хоть какой-то ответ). - return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER) - - # Основной случай: обычный текст без вложений/ссылок/признаков нужды в - # интернете — целиком к OpenRouter, Gemini — резерв на случай отказа всей - # цепочки OpenRouter разом. - if is_heavy: - return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN) - return _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN) - +# Конфигурация моделей и логика построения маршрута (GEMINI_MODELS, TEXT_MODEL_ORDER, +# _OR_MODEL_HEALTH/_ROUTER_EXCLUDED_OR_MODELS, цепочки, _build_route и т.д.) вынесены +# в lumen_router_config.py — см. импорт в начале файла (там же, где раньше был +# GEMINI_MODELS, чтобы порядок определения имён для остального кода не менялся). async def _run_route( chat_id: int, ai_prompt: str, route: list[tuple[str, str]], message: Message, *,