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"?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
- text = re.sub(r"<(?:b|strong)>(.*?)(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
- text = re.sub(r"<(?:i|em)>(.*?)(?: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}{tag}>")
-
- 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, *,