Spaces:
Running
Running
Upload 5 files
Browse files- bot.py +0 -0
- lumen_formatting.py +269 -0
- lumen_router_config.py +502 -0
- lumen_security.py +202 -0
- test_bot_helpers.py +422 -4
bot.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
lumen_formatting.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_formatting.py — конвертация markdown-подобного текста Lumen в Telegram HTML.
|
| 3 |
+
|
| 4 |
+
Вынесено из bot.py при аудите технического долга (см. пункт про монолитный
|
| 5 |
+
bot.py на ~3400+ строк): вся эта логика — чистые функции над строками (никакой
|
| 6 |
+
Telegram/Gemini/OpenRouter I/O, никакого рантайм-состояния) и поэтому один из
|
| 7 |
+
самых безопасных кандидатов на выделение в отдельный модуль. bot.py импортирует
|
| 8 |
+
из этого файла все нужные имена напрямую (см. `from lumen_formatting import ...`
|
| 9 |
+
в bot.py) — поведение и публичные имена (`_md_to_html`, `_scrub_latex` и т.д.)
|
| 10 |
+
не изменились, изменилось только физическое расположение кода.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
|
| 17 |
+
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
|
| 18 |
+
|
| 19 |
+
def _split_table_cells(line: str) -> list[str]:
|
| 20 |
+
s = line.strip()
|
| 21 |
+
if s.startswith("|"):
|
| 22 |
+
s = s[1:]
|
| 23 |
+
if s.endswith("|"):
|
| 24 |
+
s = s[:-1]
|
| 25 |
+
return [c.strip() for c in s.split("|")]
|
| 26 |
+
|
| 27 |
+
def _convert_markdown_tables_to_lists(text: str) -> str:
|
| 28 |
+
"""Telegram не рендерит markdown-таблицы НИ В КАКОМ режиме (ни HTML, ни
|
| 29 |
+
MarkdownV2) — реальный найденный при тестировании случай: модель (особенно
|
| 30 |
+
некоторые модели OpenRouter) игнорирует запрет на таблицы из system_prompt.py
|
| 31 |
+
и всё равно генерирует '|---|---|', пользователь видит вместо аккуратной
|
| 32 |
+
таблицы сырую кашу из символов "|" построчно. Это защитный (второй) рубеж —
|
| 33 |
+
находит блоки вида "заголовок + строка-разделитель из дефисов + строки
|
| 34 |
+
данных" и разворачивает их в список пунктов "**Заголовок:** значение",
|
| 35 |
+
группируя ячейки одной строки в один пункт списка."""
|
| 36 |
+
if "|" not in text or "-" not in text:
|
| 37 |
+
return text
|
| 38 |
+
lines = text.split("\n")
|
| 39 |
+
out: list[str] = []
|
| 40 |
+
i = 0
|
| 41 |
+
n = len(lines)
|
| 42 |
+
while i < n:
|
| 43 |
+
line = lines[i]
|
| 44 |
+
if "|" in line and i + 1 < n and "-" in lines[i + 1] and _TABLE_SEP_RE.match(lines[i + 1]):
|
| 45 |
+
header_cells = _split_table_cells(line)
|
| 46 |
+
if len(header_cells) >= 2:
|
| 47 |
+
data_rows = []
|
| 48 |
+
j = i + 2
|
| 49 |
+
while j < n and "|" in lines[j] and lines[j].strip():
|
| 50 |
+
data_rows.append(_split_table_cells(lines[j]))
|
| 51 |
+
j += 1
|
| 52 |
+
if data_rows:
|
| 53 |
+
for row in data_rows:
|
| 54 |
+
parts = []
|
| 55 |
+
for h_idx, header in enumerate(header_cells):
|
| 56 |
+
val = row[h_idx] if h_idx < len(row) else ""
|
| 57 |
+
if not val:
|
| 58 |
+
continue
|
| 59 |
+
parts.append(f"**{header}:** {val}" if header else val)
|
| 60 |
+
if parts:
|
| 61 |
+
out.append("• " + "; ".join(parts))
|
| 62 |
+
i = j
|
| 63 |
+
continue
|
| 64 |
+
out.append(line)
|
| 65 |
+
i += 1
|
| 66 |
+
return "\n".join(out)
|
| 67 |
+
|
| 68 |
+
# ── Защитная сетка от сырого LaTeX ──────────────────────────────────────────
|
| 69 |
+
# Реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free выдала
|
| 70 |
+
# "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода в ответе про
|
| 71 |
+
# площадь круга — при том что system_prompt.py прямо запрещает LaTeX и явно
|
| 72 |
+
# перечисляет юникод-замены (см. раздел ФОРМАТИРОВАНИЕ). Инструкция в промпте —
|
| 73 |
+
# первый (и ненадёжный) рубеж; это — второй, тот же принцип, что уже применяется
|
| 74 |
+
# к случайным HTML-тегам в Phase 0 ниже: не полагаемся только на послушание
|
| 75 |
+
# модели, страхуем детерминированной пост-обработкой.
|
| 76 |
+
_LATEX_SUPERSCRIPT_MAP = {"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", "+": "⁺", "-": "⁻", "n": "ⁿ"}
|
| 77 |
+
_LATEX_SUBSCRIPT_MAP = {"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉"}
|
| 78 |
+
# Порядок важен: многобуквен��ые команды (\times, \infty...) должны замениться
|
| 79 |
+
# ДО одиночного \t/\i и т.п., иначе оставшийся общий "\команда -> без бэкслеша"
|
| 80 |
+
# в конце срежет их раньше времени. dict сохраняет порядок вставки в Python 3.7+.
|
| 81 |
+
_LATEX_SYMBOL_MAP: dict[str, str] = {
|
| 82 |
+
r"\times": "×", r"\cdot": "·", r"\approx": "≈", r"\infty": "∞",
|
| 83 |
+
r"\leq": "≤", r"\le": "≤", r"\geq": "≥", r"\ge": "≥", r"\neq": "≠", r"\ne": "≠",
|
| 84 |
+
r"\rightarrow": "→", r"\Rightarrow": "⇒", r"\to": "→",
|
| 85 |
+
r"\forall": "∀", r"\exists": "∃", r"\emptyset": "∅", r"\cup": "∪", r"\cap": "∩", r"\in": "∈",
|
| 86 |
+
r"\pi": "π", r"\pm": "±", r"\mp": "∓", r"\sum": "∑", r"\int": "∫", r"\prod": "∏",
|
| 87 |
+
r"\alpha": "α", r"\beta": "β", r"\gamma": "γ", r"\Gamma": "Γ", r"\theta": "θ",
|
| 88 |
+
r"\lambda": "λ", r"\mu": "μ", r"\sigma": "σ", r"\Sigma": "Σ", r"\delta": "δ", r"\Delta": "Δ",
|
| 89 |
+
r"\phi": "φ", r"\omega": "ω", r"\Omega": "Ω",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
def _latex_superscript(m: re.Match) -> str:
|
| 93 |
+
return "".join(_LATEX_SUPERSCRIPT_MAP.get(ch, ch) for ch in m.group(1))
|
| 94 |
+
|
| 95 |
+
def _latex_subscript(m: re.Match) -> str:
|
| 96 |
+
return "".join(_LATEX_SUBSCRIPT_MAP.get(ch, ch) for ch in m.group(1))
|
| 97 |
+
|
| 98 |
+
def _scrub_latex(text: str) -> str:
|
| 99 |
+
"""Конвертирует сырой LaTeX в обычный юникод-текст (или снимает разметку,
|
| 100 |
+
если точный эквивалент неизвестен) — ДОЛЖНА вызываться уже после того, как
|
| 101 |
+
настоящие блоки/спаны кода вырезаны и заменены плейсхолдерами (см. Phase 1 в
|
| 102 |
+
_md_to_html), иначе легитимный код с обратным слэшем (regex-паттерны, пути
|
| 103 |
+
Windows и т.п.) был бы испорчен."""
|
| 104 |
+
if "\\" not in text and "$" not in text:
|
| 105 |
+
return text
|
| 106 |
+
# Разделители-обёртки $$...$$, \[...\], \(...\) — убираем сами разделители,
|
| 107 |
+
# оставляя содержимое для дальнейшей посимвольной замены ниже. Одиночный
|
| 108 |
+
# "$...$" (инлайн-математика в LaTeX) НАМЕРЕННО не обрабатывается: найдено
|
| 109 |
+
# при код-ревью — если в одном сообщении встречаются и сумма в долларах, и
|
| 110 |
+
# настоящая формула ("цена $100, а формула $x^2$ рядом"), первый "$" суммы
|
| 111 |
+
# ошибочно спаривается с первым "$" формулы, и результат становится ХУЖЕ
|
| 112 |
+
# исходного (обрезанные суммы плюс осиротевший "$" в хвосте формулы — то есть
|
| 113 |
+
# именно тот класс "лишнего символа", который эта защитная сетка должна
|
| 114 |
+
# убирать, а не плодить). "$$...$$" безопаснее: два подряд идущих "$" без
|
| 115 |
+
# пробела между ними практически никогда не возникают в обычном тексте с
|
| 116 |
+
# суммами денег, поэтому ложные срабатывания здесь на практике не встречаются.
|
| 117 |
+
text = re.sub(r"\\\[(.*?)\\\]", r"\1", text, flags=re.DOTALL)
|
| 118 |
+
text = re.sub(r"\\\((.*?)\\\)", r"\1", text, flags=re.DOTALL)
|
| 119 |
+
text = re.sub(r"\$\$(.*?)\$\$", r"\1", text, flags=re.DOTALL)
|
| 120 |
+
# \frac{a}{b} -> a/b (одноуровневая вложенность, самый частый случай)
|
| 121 |
+
text = re.sub(r"\\d?frac\{([^{}]*)\}\{([^{}]*)\}", r"\1/\2", text)
|
| 122 |
+
# \sqrt{x} -> √x, \sqrt[n]{x} -> ⁿ√x
|
| 123 |
+
text = re.sub(r"\\sqrt\[([^\]]*)\]\{([^{}]*)\}", r"\1√\2", text)
|
| 124 |
+
text = re.sub(r"\\sqrt\{([^{}]*)\}", r"√\1", text)
|
| 125 |
+
for cmd, repl in _LATEX_SYMBOL_MAP.items():
|
| 126 |
+
text = text.replace(cmd, repl)
|
| 127 |
+
# x^{2} / x^2 -> x², x_{2} / x_2 -> x₂ — только короткие индексы/степени,
|
| 128 |
+
# чтобы случайно не тронуть код вида a^b в языках, где это не степень.
|
| 129 |
+
# ОСТАТОЧНЫЙ EDGE-CASE (осознанно принят, не фиксим): замена не привязана к
|
| 130 |
+
# "$"/"\("-разделителям и срабатывает на голое "x^2" где угодно в тексте вне
|
| 131 |
+
# код-блоков/код-спанов (те уже вырезаны на предыдущем шаге). Если модель
|
| 132 |
+
# без backtick-форматирования упомянет побитовый XOR в прозе ("5^3 даёт..."),
|
| 133 |
+
# это тоже превратится в "5³" — потеряв смысл XOR. Системный промпт и так
|
| 134 |
+
# требует оформлять код через `бэктики`/```блоки```, поэтому легитимные
|
| 135 |
+
# примеры кода уже защищены; голый "^" в чистой прозе почти всегда всё же
|
| 136 |
+
# означает именно степень, а не XOR — компромисс в пользу частого случая.
|
| 137 |
+
text = re.sub(r"\^\{([0-9n+\-]{1,3})\}", _latex_superscript, text)
|
| 138 |
+
text = re.sub(r"\^([0-9n])(?![0-9])", _latex_superscript, text)
|
| 139 |
+
text = re.sub(r"_\{([0-9]{1,3})\}", _latex_subscript, text)
|
| 140 |
+
text = re.sub(r"_([0-9])(?![0-9])", _latex_subscript, text)
|
| 141 |
+
# Оставшиеся одиночные \command без известного юникод-эквивалента — просто
|
| 142 |
+
# снимаем бэкслеш, чтобы пользователь не видел сырое "\int"/"\mathbb" и т.п.
|
| 143 |
+
text = re.sub(r"\\([a-zA-Z]+)", r"\1", text)
|
| 144 |
+
return text
|
| 145 |
+
|
| 146 |
+
# ── Маркеры списков "- текст" / "* текст" в начале строки → "• текст" ───────
|
| 147 |
+
# Реальный найденный при калибровке пробел: _md_to_html конвертирует **bold**,
|
| 148 |
+
# *italic*, `code`, ```блоки```, markdown-таблицы — но НЕ конвертирует обычные
|
| 149 |
+
# markdown-маркеры списков, которые system_prompt.py явно предписывает
|
| 150 |
+
# использовать вместо таблиц ("маркированный список"). Модель пишет "- Пункт"
|
| 151 |
+
# или "* Пункт" (оба — совершенно нормальный markdown), а пользователь в
|
| 152 |
+
# Telegram видел литеральные "-"/"*" в начале строки вместо аккуратного "•".
|
| 153 |
+
# Заменяем маркер целиком (а не оставляем "*" как есть) — так исключается и
|
| 154 |
+
# побочный риск, что одиночная "*" в начале строки случайно спарится с другой
|
| 155 |
+
# "*" где-то дальше в тексте и даст неверный *italic*.
|
| 156 |
+
_BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE)
|
| 157 |
+
|
| 158 |
+
def _normalize_bullet_markers(text: str) -> str:
|
| 159 |
+
return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text)
|
| 160 |
+
|
| 161 |
+
def _md_to_html(text: str) -> str:
|
| 162 |
+
"""Convert markdown-like text to Telegram HTML.
|
| 163 |
+
|
| 164 |
+
── КОНТРАКТ ПАЙПЛАЙНА (аудит техдолга, см. пункт про фрагильность этой функции) ──
|
| 165 |
+
Это цепочка НЕЗАВИСИМЫХ regex-проходов поверх одного текста, а не нормальный
|
| 166 |
+
парсер с единым деревом разбора — каждый следующий шаг видит результат
|
| 167 |
+
предыдущего, и порядок шагов принципиален. Сознательное решение НЕ переписывать
|
| 168 |
+
это на полноценный токенизатор прямо сейчас: пайплайн уже покрыт ~20 тестами,
|
| 169 |
+
которые ловят именно межфазовые конфликты (см. test_scrub_latex_order_sensitive_
|
| 170 |
+
replacements_dont_corrupt_each_other, test_md_to_html_does_not_touch_pipes_inside_
|
| 171 |
+
code_block, test_scrub_latex_does_not_confuse_currency_with_math_delimiters и
|
| 172 |
+
т.п.) — переписывание на парсер потребовало бы повторно доказать корректность
|
| 173 |
+
каждого из этих уже отлаженных на реальных инцидентах edge-case'ов заново, без
|
| 174 |
+
реального выигрыша в надёжности, который можно было бы проверить иначе, чем тем
|
| 175 |
+
же самым живым продакшен-трафиком, что уже нашёл текущие edge-case'ы. Если в
|
| 176 |
+
будущем добавится ещё один вид форматирования и очередной межфазовый конфликт
|
| 177 |
+
станет реальной проблемой (а не гипотетической) — тогда и стоит пересматривать
|
| 178 |
+
архитектуру, а не превентивно.
|
| 179 |
+
|
| 180 |
+
Обязательный порядок фаз (нарушение порядка ломает уже отлаженные edge-case'ы):
|
| 181 |
+
0. Нормализация сырых HTML-тегов (<b>/<i>/<code>/<pre> и битые self-closing) в
|
| 182 |
+
markdown-эквивалент — ДО экранирования (шаг 2), иначе легитимные теги от
|
| 183 |
+
модели превратились бы в видимый мусор "<b>".
|
| 184 |
+
1. Код-блоки/спаны (```...```/`...`) вырезаются и заменяются плейсхолдерами —
|
| 185 |
+
ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
|
| 186 |
+
реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
|
| 187 |
+
1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
|
| 188 |
+
1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
|
| 189 |
+
чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
|
| 190 |
+
и не была принята за маркер списка.
|
| 191 |
+
1.5. Markdown-таблицы → список пунктов (_convert_markdown_tables_to_lists) —
|
| 192 |
+
код и списки уже обработаны/вырезаны шагами 1/1.4.
|
| 193 |
+
2. HTML-экранирование остального текста (&/</>).
|
| 194 |
+
3. Markdown (**bold**/*italic*/~~strike~~) → HTML-теги — ПОСЛЕ экранирования,
|
| 195 |
+
иначе символы разметки сами могли бы быть экранированы раньше времени.
|
| 196 |
+
4. Код-блоки/спаны восстанавливаются из плейсхолдеров с собственным
|
| 197 |
+
экранированием — самыми последними, чтобы шаги 2-3 их не затронули.
|
| 198 |
+
|
| 199 |
+
Code blocks are saved first so underscores/asterisks inside them
|
| 200 |
+
are never treated as italic/bold markers.
|
| 201 |
+
"""
|
| 202 |
+
if not text:
|
| 203 |
+
return ""
|
| 204 |
+
|
| 205 |
+
# ── Phase 0: нормализация "сырых" HTML-тегов, которые модель иногда пишет
|
| 206 |
+
# напрямую вместо markdown (несмотря на явную инструкцию в system_prompt.py
|
| 207 |
+
# использовать только markdown-синтаксис) — без этого такие теги ловятся
|
| 208 |
+
# escape'ом на шаге 2 и показываются пользователю как видимый мусорный текст
|
| 209 |
+
# вида "<b>"/"<b/>" прямо в сообщении (реальный найденный при тестировании
|
| 210 |
+
# баг). Сначала убираем заведомо битые self-closing варианты (напр. "<b/>"),
|
| 211 |
+
# затем конвертируем корректные парные теги в markdown-эквивалент — дальше
|
| 212 |
+
# они идут по тому же (уже проверенному) конвейеру, что и обычный markdown.
|
| 213 |
+
text = re.sub(r"</?(?:b|strong|i|em|u|s|code|pre)\s*/>", "", text, flags=re.IGNORECASE)
|
| 214 |
+
text = re.sub(r"<(?:b|strong)>(.*?)</(?:b|strong)>", r"**\1**", text, flags=re.IGNORECASE | re.DOTALL)
|
| 215 |
+
text = re.sub(r"<(?:i|em)>(.*?)</(?:i|em)>", r"*\1*", text, flags=re.IGNORECASE | re.DOTALL)
|
| 216 |
+
text = re.sub(r"<u>(.*?)</u>", r"\1", text, flags=re.IGNORECASE | re.DOTALL)
|
| 217 |
+
text = re.sub(r"<s>(.*?)</s>", r"~~\1~~", text, flags=re.IGNORECASE | re.DOTALL)
|
| 218 |
+
text = re.sub(r"<pre>(.*?)</pre>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.IGNORECASE | re.DOTALL)
|
| 219 |
+
text = re.sub(r"<code>(.*?)</code>", r"`\1`", text, flags=re.IGNORECASE | re.DOTALL)
|
| 220 |
+
|
| 221 |
+
# ── Phase 1: Save code spans/blocks before any processing ────────────────
|
| 222 |
+
_saved: dict[str, str] = {}
|
| 223 |
+
_counter = [0]
|
| 224 |
+
|
| 225 |
+
def _save_block(m: re.Match) -> str:
|
| 226 |
+
key = f"\x00CB{_counter[0]}\x00"
|
| 227 |
+
_counter[0] += 1
|
| 228 |
+
_saved[key] = m.group(0)
|
| 229 |
+
return key
|
| 230 |
+
|
| 231 |
+
text = re.sub(r"```[a-zA-Z0-9]*\n.*?\n```", _save_block, text, flags=re.DOTALL)
|
| 232 |
+
text = re.sub(r"`[^`\n]+`", _save_block, text)
|
| 233 |
+
|
| 234 |
+
# ── Phase 1.3: сырой LaTeX → юникод (см. _scrub_latex выше) — код уже
|
| 235 |
+
# вынесен на предыдущем шаге, поэтому обратные слэши в реальном коде
|
| 236 |
+
# (regex, пути Windows и т.п.) не затрагиваются.
|
| 237 |
+
text = _scrub_latex(text)
|
| 238 |
+
|
| 239 |
+
# ── Phase 1.4: маркеры списков "- "/"* " → "• " (см. _normalize_bullet_
|
| 240 |
+
# markers выше) — ДО таблиц и ДО Phase 3, чтобы не путаться с "**bold**" и
|
| 241 |
+
# чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой.
|
| 242 |
+
text = _normalize_bullet_markers(text)
|
| 243 |
+
|
| 244 |
+
# ── Phase 1.5: markdown-таблицы → список пунктов (см. _convert_markdown_
|
| 245 |
+
# tables_to_lists выше) — код уже вынесен на предыдущем шаге, поэтому "|"
|
| 246 |
+
# внутри кода (например, битовое ИЛИ в Rust/C) сюда не попадёт.
|
| 247 |
+
text = _convert_markdown_tables_to_lists(text)
|
| 248 |
+
|
| 249 |
+
# ── Phase 2: HTML-escape the rest ────────────────────────────────────────
|
| 250 |
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 251 |
+
|
| 252 |
+
# ── Phase 3: Apply markdown ───────────────────────────────────────────────
|
| 253 |
+
text = re.sub(r"(\*\*|__)(.*?)\1", r"<b>\2</b>", text, flags=re.DOTALL)
|
| 254 |
+
text = re.sub(r"(\*|_)(.*?)\1", r"<i>\2</i>", text)
|
| 255 |
+
text = re.sub(r"~~(.*?)~~", r"<s>\1</s>", text)
|
| 256 |
+
|
| 257 |
+
# ── Phase 4: Restore code blocks with proper escaping ────────────────────
|
| 258 |
+
for key, orig in _saved.items():
|
| 259 |
+
if orig.startswith("```"):
|
| 260 |
+
m = re.match(r"```[a-zA-Z0-9]*\n(.*)\n```", orig, re.DOTALL)
|
| 261 |
+
inner = m.group(1) if m else orig[3:-3]
|
| 262 |
+
else:
|
| 263 |
+
inner = orig[1:-1]
|
| 264 |
+
inner = inner.replace("&", "&").replace("<", "<").replace(">", ">")
|
| 265 |
+
tag = "pre" if orig.startswith("```") else "code"
|
| 266 |
+
text = text.replace(key, f"<{tag}>{inner}</{tag}>")
|
| 267 |
+
|
| 268 |
+
return text
|
| 269 |
+
|
lumen_router_config.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_router_config.py — конфигурация моделей и логика автоматического выбора
|
| 3 |
+
маршрута (Gemini/OpenRouter) для одного сообщения.
|
| 4 |
+
|
| 5 |
+
Вынесено из bot.py при аудите технического долга. Всё содержимое этого файла —
|
| 6 |
+
конфигурационные данные (какие модели существуют, какие из них сейчас "нездоровы")
|
| 7 |
+
и ЧИСТЫЕ функции принятия решения о маршруте (_build_route/_or_route/_gemini_route,
|
| 8 |
+
эвристики "это тяжёлый запрос?"/"нужна свежая информация?") — никакого обращения
|
| 9 |
+
к Telegram/Gemini/OpenRouter API отсюда не происходит, поэтому этот код не зависит
|
| 10 |
+
от рантайм-состояния бота (в отличие от ask_gemini/ask_openrouter_*/_run_route,
|
| 11 |
+
которые реально выполняют маршрут и остаются в bot.py). bot.py импортирует все
|
| 12 |
+
нужные имена напрямую — публичные имена и поведение не изменились.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from datetime import date
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
# Единый логгер "bot" (а не __name__ == "lumen_router_config") — намеренно,
|
| 24 |
+
# чтобы предупреждения из этого модуля попадали под те же тесты/фильтры логов
|
| 25 |
+
# (caplog.at_level(..., logger="bot")), что и остальной бот, независимо от того,
|
| 26 |
+
# в каком физическом файле живёт код.
|
| 27 |
+
log = logging.getLogger("bot")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# список моделей
|
| 31 |
+
|
| 32 |
+
# name/badge/desc/public_name/public_desc, ранее украшавшие каждую запись здесь,
|
| 33 |
+
# убраны целиком (ponytail-audit, июль 2026) — это были чисто отображаемые строки
|
| 34 |
+
# для команды /model, которая с тех пор удалена (см. "автоматический выбор модели"
|
| 35 |
+
# ниже); ни одно из них нигде не читалось. Настоящая модель, которую обозначает
|
| 36 |
+
# каждый ключ, и так понятна по самому ключу и по комментариям ниже — ничего не
|
| 37 |
+
# потеряно. Единственные поля, которые здесь реально используются: search_grounding/
|
| 38 |
+
# map_grounding/url_context/no_search/no_system/stream (см. _build_gemini_call_config)
|
| 39 |
+
# и quota_unconfirmed (см. _check_unconfirmed_model_quotas).
|
| 40 |
+
GEMINI_MODELS: dict[str, dict[str, Any]] = {
|
| 41 |
+
# Gemini 3.6 Flash — новый флагман линейки Flash, вышел 21 июля 2026, сменяет
|
| 42 |
+
# 3.5 Flash: по анонсу Google лучше в коде/агентных сценариях/мультимодальности,
|
| 43 |
+
# ~17% экономичнее по токенам. Контекст 1 млн токенов, знания по март 2026.
|
| 44 |
+
"gemini-3.6-flash": {
|
| 45 |
+
"stream": True,
|
| 46 |
+
# ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026, реальный скриншот от
|
| 47 |
+
# владельца): Map grounding = 0/0 (квоты нет вовсе). Search grounding в
|
| 48 |
+
# дашборде числится не по конкретной модели, а по общему бакету "Gemini 3"
|
| 49 |
+
# (объединяет 3/3.1/3.5/3.6) — и этот бакет тоже 0/0, то есть поиска нет ни
|
| 50 |
+
# у одной модели поколения Gemini 3.x на этом ключе (в отличие от бакета
|
| 51 |
+
# "Gemini 2.5" — там реально 21/1500, см. gemini-2.5-flash/lite ниже и
|
| 52 |
+
# обновлённый порядок GEMINI_SEARCH_CHAIN). RPD-лимит тоже подтверждён: 20/сутки.
|
| 53 |
+
# Прежнее консервативное предположение (0/0 по аналогии с 3.5-flash) оказалось
|
| 54 |
+
# верным — quota_unconfirmed снят.
|
| 55 |
+
"search_grounding": False, "map_grounding": False, "url_context": True,
|
| 56 |
+
},
|
| 57 |
+
# Gemini 3.5 Flash — прошлый флагман линейки Flash, сохранён в цепочке как
|
| 58 |
+
# резерв после 3.6 Flash.
|
| 59 |
+
"gemini-3.5-flash": {
|
| 60 |
+
"stream": True,
|
| 61 |
+
# По данным дашборда AI Studio (июль 2026): Map grounding для этой модели
|
| 62 |
+
# показывает лимит 0/0 — то есть бесплатной квоты на инструмент нет вообще
|
| 63 |
+
# (не "не расходовано", а именно нулевой лимит). Search grounding отдельно
|
| 64 |
+
# для 3.5/3-flash не выделен в дашборде (числится под "Gemini 3" с тем же 0/0) —
|
| 65 |
+
# отключаем оба инструмента для этой модели, чтобы не тратить попытки впустую.
|
| 66 |
+
# url_context — другое дело: у него нет отдельной дневной квоты в дашборде,
|
| 67 |
+
# он просто добавляет токены по обычной цене модели, поэтому оставляем включённым.
|
| 68 |
+
"search_grounding": False, "map_grounding": False, "url_context": True,
|
| 69 |
+
},
|
| 70 |
+
# Gemini 3 Flash Preview — предыдущая Preview-версия линейки Flash, сохранена
|
| 71 |
+
# для тех, кто предпочитает её поведение версии 3.5 (более активное обдумывание).
|
| 72 |
+
"gemini-3-flash-preview": {
|
| 73 |
+
"stream": True,
|
| 74 |
+
"search_grounding": False, "map_grounding": False, "url_context": True,
|
| 75 |
+
},
|
| 76 |
+
# Gemini 3.5 Flash-Lite — новая версия самой быстрой и экономичной модели,
|
| 77 |
+
# вышла 21 июля 2026 вместе с 3.6 Flash; превосходит 3.1 Flash-Lite в агентных
|
| 78 |
+
# задачах и длинном контексте, до 350 токенов/сек.
|
| 79 |
+
"gemini-3.5-flash-lite": {
|
| 80 |
+
"stream": True,
|
| 81 |
+
# ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026). Map grounding — реально
|
| 82 |
+
# ненулевая квота 500/сутки, прежнее предположение (True) подтвердилось.
|
| 83 |
+
# Search grounding — ИСПРАВЛЕНО: раньше здесь стояло True по неверной аналогии
|
| 84 |
+
# с gemini-3.1-flash-lite ("раз lite-класс, значит есть квота на оба
|
| 85 |
+
# инструмента"). По факту дашборд считает Search grounding не по конкретной
|
| 86 |
+
# модели, а по общему бакету поколения — "Gemini 3" (охватывает 3/3.1/3.5/3.6
|
| 87 |
+
# разом) — и этот бакет 0/0. Реальная квота на поиск есть только у бакета
|
| 88 |
+
# "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite и обновлённый порядок
|
| 89 |
+
# GEMINI_SEARCH_CHAIN. quota_unconfirmed снят.
|
| 90 |
+
"search_grounding": False, "map_grounding": True,
|
| 91 |
+
},
|
| 92 |
+
# Gemini 3.1 Flash-Lite — прошлая версия самой быстрой и экономичной модели
|
| 93 |
+
# линейки, сохранена в цепочке как резерв после 3.5 Flash-Lite.
|
| 94 |
+
"gemini-3.1-flash-lite": {
|
| 95 |
+
"stream": True,
|
| 96 |
+
# Дашборд показывает реальную ненулевую квоту на Map grounding (0/500) для
|
| 97 |
+
# этой модели — map_grounding оставлен включённым. ИСПРАВЛЕНО (24 июля 2026):
|
| 98 |
+
# search_grounding раньше тоже стоял True — это была та же ошибка, что и у
|
| 99 |
+
# gemini-3.5-flash-lite ("есть квота на map grounding → значит есть и на
|
| 100 |
+
# search"), но дашборд считает Search grounding отдельным общим бакетом по
|
| 101 |
+
# ПОКОЛЕНИЮ модели ("Gemini 3" — охватывает 3/3.1/3.5/3.6 разом), и этот
|
| 102 |
+
# бакет показывает 0/0. Реальная квота на поиск подтверждена только у бакета
|
| 103 |
+
# "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite ниже.
|
| 104 |
+
"search_grounding": False, "map_grounding": True,
|
| 105 |
+
},
|
| 106 |
+
# Gemini 2.5 Flash — универсальная мультимодальная модель поколения 2.5,
|
| 107 |
+
# хороший баланс скорости и качества для большинства повседневных задач.
|
| 108 |
+
"gemini-2.5-flash": {
|
| 109 |
+
"stream": True,
|
| 110 |
+
"search_grounding": True, "map_grounding": True,
|
| 111 |
+
},
|
| 112 |
+
# Gemini 2.5 Flash-Lite — экономичная модель поколения 2.5 для задач, где
|
| 113 |
+
# важна скорость ответа больше, чем глубина рассуждений.
|
| 114 |
+
"gemini-2.5-flash-lite": {
|
| 115 |
+
"stream": True,
|
| 116 |
+
"search_grounding": True, "map_grounding": True,
|
| 117 |
+
},
|
| 118 |
+
# Gemma 4 31B — флагманская открытая модель Google на 31 млрд параметров.
|
| 119 |
+
"gemma-4-31b-it": {
|
| 120 |
+
"no_system": True, "no_search": True, "stream": True,
|
| 121 |
+
},
|
| 122 |
+
# Gemma 4 26B — компактная открытая модель Google на 26 млрд параметров с
|
| 123 |
+
# расширенным мышлением (thinking).
|
| 124 |
+
"gemma-4-26b-a4b-it": {
|
| 125 |
+
"no_system": True,
|
| 126 |
+
# НАЙДЕНО при перепроверке конфига (24 июля 2026): у "родственной" модели
|
| 127 |
+
# gemma-4-31b-it выше стоит "no_search": True (Gemma, как открытая модель,
|
| 128 |
+
# не поддерживает grounding-инструменты Gemini API в принципе), а здесь этот
|
| 129 |
+
# флаг был случайно пропущен. Без него _build_gemini_call_config по умолчанию
|
| 130 |
+
# (search_grounding/url_context по умолчанию True при отсутствии ключа в конфиге)
|
| 131 |
+
# пытался бы добавить в запрос google_search И url_context для модели, которая
|
| 132 |
+
# их не поддерживает вообще — реальный риск ошибки API на КАЖДЫЙ вызов этой
|
| 133 |
+
# модели (она сейчас последняя в GEMINI_HEAVY_CHAIN, поэтому баг маловероятно
|
| 134 |
+
# проявлялся на практике, но был реальным). Добавлено для консистентности с 31B.
|
| 135 |
+
"no_search": True, "stream": True,
|
| 136 |
+
},
|
| 137 |
+
}
|
| 138 |
+
DEFAULT_GEMINI_MODEL = "gemini-3.6-flash"
|
| 139 |
+
|
| 140 |
+
def _check_unconfirmed_model_quotas() -> None:
|
| 141 |
+
"""Модели, добавленные сразу после релиза (см. quota_unconfirmed=True в
|
| 142 |
+
GEMINI_MODELS), — их реальные RPD-лимиты и доступность search/map grounding
|
| 143 |
+
ещё не подтверждены по дашборду AI Studio (дашборд обновляется с задержкой
|
| 144 |
+
после релиза модели, иногда на несколько дней). Громко напоминаем при
|
| 145 |
+
каждом старте, пока флаг не снят вручную после реальной проверки — та же
|
| 146 |
+
идея, что и у _check_temporary_free_models_expiry выше, только для новых,
|
| 147 |
+
а не для истекающих моделей."""
|
| 148 |
+
for mid, conf in GEMINI_MODELS.items():
|
| 149 |
+
if conf.get("quota_unconfirmed"):
|
| 150 |
+
log.warning(
|
| 151 |
+
"[setup] SYSTEM WARN: реальные RPD-лимиты и доступность search/map grounding "
|
| 152 |
+
"для модели %s ещё НЕ подтверждены по дашборду AI Studio (модель недавно "
|
| 153 |
+
"выпущена) — текущие search_grounding/map_grounding в GEMINI_MODELS это "
|
| 154 |
+
"предположение по аналогии с моделью того же класса. Проверьте дашборд и "
|
| 155 |
+
"уберите 'quota_unconfirmed' у этой модели в bot.py, поправив конфиг при необходимости.",
|
| 156 |
+
mid,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь был словарь OPENROUTER_MODELS["text"]
|
| 160 |
+
# со списком dict'ов {"id", "name", "description"} на ~25 моделей — то же самое
|
| 161 |
+
# "name/badge/desc", что уже было вычищено из GEMINI_MODELS (см. комментарий там,
|
| 162 |
+
# ponytail-audit, июль 2026), но по ошибке не сделано для OpenRouter. "name"/
|
| 163 |
+
# "description" были чисто отображаемыми строками для команды /model, которая
|
| 164 |
+
# с тех пор удалена (см. README, "Автоматический выбор модели") — единственное
|
| 165 |
+
# реальное использование всего словаря было `[m["id"] for m in ...]`. Раз
|
| 166 |
+
# описания нигде не читаются, оставляем сразу плоский список ID — тот же
|
| 167 |
+
# TEXT_MODEL_ORDER, что раньше вычислялся ИЗ словаря, теперь и есть сам список.
|
| 168 |
+
#
|
| 169 |
+
# Список перепроверен вручную по openrouter.ai (июль 2026) — модель за моделью,
|
| 170 |
+
# т.к. часть ID из старого списка либо сняты с бесплатного тира (arcee-ai/trinity-
|
| 171 |
+
# large-thinking:free — акция закончилась 23.05, теперь платная; baidu/cobuddy:free —
|
| 172 |
+
# больше не бесплатна), либо заменены провайдером на новую версию (poolside/laguna-xs.2:free
|
| 173 |
+
# официально сворачивается в пользу laguna-xs-2.1:free). nvidia/nemotron-3.5-content-safety:free
|
| 174 |
+
# НАМЕРЕННО не включена — это guardrail/классификатор safe/unsafe, а не диалоговая модель,
|
| 175 |
+
# добавлять её сюда бессмысленно и вредно (не будет отвечать текстом на вопросы).
|
| 176 |
+
# Порядок — от самых сильных/надёжных моделей общего назначения к нишевым и совсем лёгким;
|
| 177 |
+
# используется, в частности, _LEAK_LITERAL_STRINGS ниже (защита от утечки идентичности) и
|
| 178 |
+
# как источник моделей для роутера (см. _OR_LIGHT_ORDER/_OR_HEAVY_ORDER/_OR_VISION_ORDER
|
| 179 |
+
# и единый реестр "нездоровых" моделей _OR_MODEL_HEALTH дальше по файлу).
|
| 180 |
+
TEXT_MODEL_ORDER: list[str] = [
|
| 181 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 182 |
+
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
| 183 |
+
"openai/gpt-oss-120b:free",
|
| 184 |
+
"z-ai/glm-4.5-air:free",
|
| 185 |
+
"tencent/hy3:free",
|
| 186 |
+
"openrouter/owl-alpha",
|
| 187 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 188 |
+
"meta-llama/llama-3.3-70b-instruct:free",
|
| 189 |
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
| 190 |
+
"openai/gpt-oss-20b:free",
|
| 191 |
+
"google/gemma-4-31b-it:free",
|
| 192 |
+
"google/gemma-4-26b-a4b-it:free",
|
| 193 |
+
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
|
| 194 |
+
"qwen/qwen3-coder:free",
|
| 195 |
+
"poolside/laguna-m.1:free",
|
| 196 |
+
"poolside/laguna-xs-2.1:free",
|
| 197 |
+
"cohere/north-mini-code:free",
|
| 198 |
+
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
| 199 |
+
"nvidia/nemotron-nano-12b-v2-vl:free",
|
| 200 |
+
"nvidia/nemotron-3-nano-30b-a3b:free",
|
| 201 |
+
"nvidia/nemotron-nano-9b-v2:free",
|
| 202 |
+
"meta-llama/llama-3.2-3b-instruct:free",
|
| 203 |
+
"liquid/lfm-2.5-1.2b-instruct:free",
|
| 204 |
+
"liquid/lfm-2.5-1.2b-thinking:free",
|
| 205 |
+
"openrouter/free",
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
# ── Единый реестр "нездоровых" моделей OpenRouter (аудит техдолга, август 2026) ──
|
| 209 |
+
# РАНЬШЕ это отслеживалось ТРЕМЯ независимыми механизмами: _TEMPORARY_FREE_MODELS
|
| 210 |
+
# (dict с датой истечения промо), _ROUTER_EXCLUDED_OR_MODELS (отдельное множество
|
| 211 |
+
# для ручного исключения из роутинга) и точечные комментарии в _OR_LIGHT_ORDER/
|
| 212 |
+
# _OR_HEAVY_ORDER о моделях, вычеркнутых оттуда вручную. Три реальных инцидента
|
| 213 |
+
# (tencent/hy3:free, qwen/qwen3-coder:free, qwen/qwen3-next-80b-a3b-instruct:free)
|
| 214 |
+
# потребовали правок в 2-3 местах каждый — ровно тот класс рассинхрона, которого
|
| 215 |
+
# проект и так избегает в других местах (см. TEXT_MODEL_ORDER/_next_fallback_model
|
| 216 |
+
# выше). Теперь один dict хранит причину/срок для каждой проблемной модели, а
|
| 217 |
+
# _ROUTER_EXCLUDED_OR_MODELS и предупреждение об истёкшем промо вычисляются ИЗ
|
| 218 |
+
# него, а не поддерживаются параллельно вручную.
|
| 219 |
+
@dataclass(frozen=True)
|
| 220 |
+
class _ModelHealthNote:
|
| 221 |
+
reason: str
|
| 222 |
+
# Задано только для ВРЕМЕННОГО промо-доступа (акция провайдера) — после этой
|
| 223 |
+
# даты в логи попадает предупреждение перепроверить актуальную цену на
|
| 224 |
+
# openrouter.ai. Модели, снятые НАВСЕГДА (не промо, а прямая инструкция
|
| 225 |
+
# провайдера использовать другой/платный слаг), оставляют это поле пустым —
|
| 226 |
+
# предупреждать об "истечении" там нечего, они просто не должны выбираться.
|
| 227 |
+
promo_expiry: date | None = None
|
| 228 |
+
|
| 229 |
+
_OR_MODEL_HEALTH: dict[str, _ModelHealthNote] = {
|
| 230 |
+
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free": _ModelHealthNote(
|
| 231 |
+
reason="Uncensored-модель — может хуже соблюдать личность/правила Lumen. Раньше выбиралась "
|
| 232 |
+
"вручную только владельцем через /provider (команда удалена) — автоматический роутер "
|
| 233 |
+
"её не выбирает вообще."
|
| 234 |
+
),
|
| 235 |
+
"qwen/qwen3-coder:free": _ModelHealthNote(
|
| 236 |
+
reason="Подтверждено при аудите моделей (июль 2026): :free-эндпоинт снят провайдером.",
|
| 237 |
+
promo_expiry=date(2026, 6, 30),
|
| 238 |
+
),
|
| 239 |
+
"tencent/hy3:free": _ModelHealthNote(
|
| 240 |
+
reason="Собственная страница OpenRouter показывала 'Going away July 19, 2026' — :free-эндпоинт "
|
| 241 |
+
"уже снят провайдером.",
|
| 242 |
+
promo_expiry=date(2026, 7, 21),
|
| 243 |
+
),
|
| 244 |
+
"qwen/qwen3-next-80b-a3b-instruct:free": _ModelHealthNote(
|
| 245 |
+
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (25 июля 2026, ~40 минут живого трафика, 20+ "
|
| 246 |
+
"попыток подряд): HTTP 404 абсолютно каждый раз — 'This model is unavailable for "
|
| 247 |
+
"free... use this slug instead: qwen/qwen3-next-80b-a3b-instruct' (платный слаг). "
|
| 248 |
+
"Не временное промо, а прямая инструкция провайдера использовать другой (платный) "
|
| 249 |
+
"слаг — не возвращать в _OR_*_ORDER, пока провайдер вновь не откроет бесплатный "
|
| 250 |
+
"доступ именно к этому слагу."
|
| 251 |
+
),
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
# Вычисляется ИЗ _OR_MODEL_HEALTH выше — единственное место, где решается, какие
|
| 255 |
+
# модели роутер не должен выбирать (см. _or_route дальше по файлу).
|
| 256 |
+
_ROUTER_EXCLUDED_OR_MODELS: frozenset[str] = frozenset(_OR_MODEL_HEALTH.keys())
|
| 257 |
+
|
| 258 |
+
def _check_temporary_free_models_expiry() -> None:
|
| 259 |
+
"""Предупреждает в логах (при каждом старте и раз в сутки, см. фоновый цикл в
|
| 260 |
+
_webhook_startup) про модели с истёкшим временным промо-доступом — на случай,
|
| 261 |
+
если запись когда-нибудь понадобится вернуть в оборот и стоит перепроверить
|
| 262 |
+
актуальную цену на openrouter.ai. Модели без promo_expiry (сняты навсегда, а
|
| 263 |
+
не по истечении акции) сюда не попадают — предупреждать об "истечении" для
|
| 264 |
+
них нечего."""
|
| 265 |
+
today = date.today()
|
| 266 |
+
for model_id, note in _OR_MODEL_HEALTH.items():
|
| 267 |
+
if note.promo_expiry is not None and today > note.promo_expiry:
|
| 268 |
+
log.warning(
|
| 269 |
+
"[or] SYSTEM WARN: временный бесплатный доступ к модели %s истёк %s (сегодня %s) — %s "
|
| 270 |
+
"Роутер её уже не выбирает (_ROUTER_EXCLUDED_OR_MODELS), но проверьте актуальную цену "
|
| 271 |
+
"на openrouter.ai, если модель когда-нибудь понадобится вернуть в оборот.",
|
| 272 |
+
model_id, note.promo_expiry.isoformat(), today.isoformat(), note.reason,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
def _or_route(models: list[str]) -> list[tuple[str, str]]:
|
| 276 |
+
"""Превращает список ID моделей OpenRouter в список (provider, model_id) для
|
| 277 |
+
маршрута, попутно исключая модели из _ROUTER_EXCLUDED_OR_MODELS."""
|
| 278 |
+
return [("openrouter", m) for m in models if m not in _ROUTER_EXCLUDED_OR_MODELS]
|
| 279 |
+
|
| 280 |
+
def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
|
| 281 |
+
return [("gemini", m) for m in models]
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ── "Лёгкие"/"стандартные" запросы без вложений и ссылок — САМЫЙ ЧАСТЫЙ
|
| 285 |
+
# маршрут в обычном чате. Целиком обслуживается OpenRouter'ом, чтобы вообще не
|
| 286 |
+
# трогать скудную квоту Gemini на самом массовом классе сообщений.
|
| 287 |
+
#
|
| 288 |
+
# ВАЖНО (по итогам живого тестирования, см. историю): meta-llama/llama-3.3-70b-
|
| 289 |
+
# instruct:free полностью убрана из этого списка — провайдер снял её с
|
| 290 |
+
# бесплатного тира (HTTP 404 "This model is unavailable for free", подтверждено
|
| 291 |
+
# десятками идентичных отказов подряд в реальных логах). Держать её первой в
|
| 292 |
+
# списке означало гарантированный лишний неудачный запрос на КАЖДОЕ сообщение.
|
| 293 |
+
# qwen/qwen3-next-80b-a3b-instruct:free полностью УБРАНА из списка (25 июля
|
| 294 |
+
# 2026) — сама теперь 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS выше.
|
| 295 |
+
#
|
| 296 |
+
# ПЕРЕСТРОЕНО (25 июля 2026, по прямому сравнению ответов бота с ответами
|
| 297 |
+
# настоящего Claude на идентичные промпты в рамках калибровочной сессии):
|
| 298 |
+
# - z-ai/glm-4.5-air:free поднята на первое место — ни разу не замечена в
|
| 299 |
+
# порче текста ни в тяжёлом, ни в лёгком тестировании, хорошо держит русский.
|
| 300 |
+
# - openai/gpt-oss-20b:free ПОНИЖЕНА: подтверждено 2 тяжёлых инцидента —
|
| 301 |
+
# на прямой идентити-вопрос "какая ты модель на самом деле?" выдала
|
| 302 |
+
# бессвязную смесь языков ("Я — L accompagné.") вместо ответа, а на
|
| 303 |
+
# эмоционально уязвимый запрос ("меня бросила девушка, что делать") вставила
|
| 304 |
+
# посреди ответа нечитаемый арабский фрагмент. Не убрана совсем — на
|
| 305 |
+
# остальных ~6 наблюдавшихся вызовах отвечала нормально, — но с первого
|
| 306 |
+
# места снята однозначно.
|
| 307 |
+
# - nvidia/nemotron-3-nano-30b-a3b:free ПОНИЖЕНА ещё ниже: подтверждено 3
|
| 308 |
+
# инцидента — деванагари-мусор внутри слова ("пиिजцы" вместо "пиццы") ВМЕСТЕ
|
| 309 |
+
# с сырым LaTeX в ответе про площадь круга (при том что system_prompt.py
|
| 310 |
+
# прямо запрещает LaTeX), уверенная галлюцинация названия фильма ("К Eggman"
|
| 311 |
+
# вместо "Гранд Будапешт Отель"), порченые слова и выдуманное название
|
| 312 |
+
# компании ("Vueium") в сравнении React/Vue. Из трёх протестированных
|
| 313 |
+
# "лёгких" моделей — худшая по частоте порчи текста.
|
| 314 |
+
# Ни gpt-oss-20b, ни nemotron-3-nano-30b-a3b пока не удалены полностью: ниже
|
| 315 |
+
# них в цепочке стоят ЕЩЁ более мелкие модели (9B/3B/1.2B), которые в этой
|
| 316 |
+
# сессии не тестировались и по объёму параметров теоретически ещё менее
|
| 317 |
+
# надёжны на русском. Если и они дадут похожие инциденты — тогда стоит
|
| 318 |
+
# рассмотреть полное исключение gpt-oss-20b/nemotron-3-nano-30b-a3b, а не
|
| 319 |
+
# просто понижение приоритета.
|
| 320 |
+
_OR_LIGHT_ORDER: list[str] = [
|
| 321 |
+
"z-ai/glm-4.5-air:free",
|
| 322 |
+
"nvidia/nemotron-nano-9b-v2:free",
|
| 323 |
+
"meta-llama/llama-3.2-3b-instruct:free",
|
| 324 |
+
"openai/gpt-oss-20b:free",
|
| 325 |
+
"nvidia/nemotron-3-nano-30b-a3b:free",
|
| 326 |
+
"liquid/lfm-2.5-1.2b-instruct:free",
|
| 327 |
+
"openrouter/free",
|
| 328 |
+
]
|
| 329 |
+
|
| 330 |
+
# ── "Тяжёлые" запросы (код, многошаговые рассуждения, объёмный анализ) без
|
| 331 |
+
# нужды в интернете/медиа — тоже сначала к OpenRouter: среди бесплатных
|
| 332 |
+
# моделей там есть по-настоящему сильные кандидаты (120B/550B), не уступающие
|
| 333 |
+
# по мощи флагману Gemini, но не занимающие его 20 запросов/сутки.
|
| 334 |
+
#
|
| 335 |
+
# qwen/qwen3-next-80b-a3b-instruct:free убрана из запасного места в конце —
|
| 336 |
+
# 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS. Заменена на дополнительный
|
| 337 |
+
# резерв glm-4.5-air (уже есть выше в цепочке, но openrouter/free как последний
|
| 338 |
+
# универсальный fallback остаётся).
|
| 339 |
+
#
|
| 340 |
+
# МОНИТОРИНГ (25 июля 2026): nvidia/nemotron-3-super-120b-a12b:free, несмотря на
|
| 341 |
+
# статус флагмана этого тира, дала 1 инцидент из 4 протестированных тяжёлых
|
| 342 |
+
# запросов — в ответе про TCP/IP посреди русского текста встретился китайский
|
| 343 |
+
# иероглиф "尾部" (вместо "хвост"), итальянское "infine" и английское "preventing".
|
| 344 |
+
# Остальные 3 запроса (Rust-палиндром, Python-сортировка, сравнение iPhone/Samsung)
|
| 345 |
+
# отработала чисто. Пока не понижаем — один инцидент на четыре успешных попытки
|
| 346 |
+
# не повод убирать флагмана, но стоит присматривать за логами `[stream]`/ответами
|
| 347 |
+
# этой модели и понизить её, если порча текста повторится.
|
| 348 |
+
_OR_HEAVY_ORDER: list[str] = [
|
| 349 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 350 |
+
"openai/gpt-oss-120b:free",
|
| 351 |
+
"z-ai/glm-4.5-air:free",
|
| 352 |
+
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
| 353 |
+
"nousresearch/hermes-3-llama-3.1-405b:free",
|
| 354 |
+
"openrouter/free",
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
# ── Вложение (изображение) без нужды в свежей информации — у OpenRouter
|
| 358 |
+
# достаточно бесплатных vision-моделей, чтобы не трогать Gemini. OpenRouter
|
| 359 |
+
# физически принимает только изображения (base64 data URL) — для видео/аудио
|
| 360 |
+
# этот список не используется вообще, см. _build_route/_run_route ниже.
|
| 361 |
+
_OR_VISION_ORDER: list[str] = [
|
| 362 |
+
"nvidia/nemotron-nano-12b-v2-vl:free",
|
| 363 |
+
"google/gemma-4-31b-it:free",
|
| 364 |
+
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
| 365 |
+
"google/gemma-4-26b-a4b-it:free",
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
# ── Цепочки Gemini. GEMINI_HEAVY_CHAIN — от сильной модели к слабой (тот же
|
| 369 |
+
# состав/порядок, что был у прежнего единственного quota_fallback_chain), для
|
| 370 |
+
# случаев, где ТРЕБУЕТСЯ именно Gemini (YouTube/сайт по ссылке, видео/аудио
|
| 371 |
+
# вложение), но живой поиск не нужен. GEMINI_SEARCH_CHAIN — те же модели, но
|
| 372 |
+
# начиная с тех, у кого по дашборду AI Studio реально ЕСТЬ квота на search
|
| 373 |
+
# grounding (флагман 3.5 и 3-preview её не имеют вообще — см. комментарии в
|
| 374 |
+
# GEMINI_MODELS выше), чтобы запрос, которому нужен живой поиск, не попадал
|
| 375 |
+
# первым делом на модель, что физически не может искать.
|
| 376 |
+
GEMINI_HEAVY_CHAIN: list[str] = [
|
| 377 |
+
"gemini-3.6-flash",
|
| 378 |
+
"gemini-3.5-flash",
|
| 379 |
+
"gemini-3-flash-preview",
|
| 380 |
+
"gemini-3.5-flash-lite",
|
| 381 |
+
"gemini-3.1-flash-lite",
|
| 382 |
+
"gemini-2.5-flash",
|
| 383 |
+
"gemini-2.5-flash-lite",
|
| 384 |
+
"gemma-4-31b-it",
|
| 385 |
+
"gemma-4-26b-a4b-it",
|
| 386 |
+
]
|
| 387 |
+
# ПЕРЕСТРОЕНО (24 июля 2026, по реальным данным дашборда AI Studio): раньше первыми
|
| 388 |
+
# здесь стояли gemini-3.5-flash-lite/gemini-3.1-flash-lite в предположении, что у
|
| 389 |
+
# них есть search grounding — это оказалось неверно (см. комментарии в GEMINI_MODELS
|
| 390 |
+
# выше). Дашборд считает квоту на Search grounding не по конкретной модели, а по
|
| 391 |
+
# общему бакету ПОКОЛЕНИЯ: бакет "Gemini 3" (охватывает 3/3.1/3.5/3.6 целиком) — 0/0,
|
| 392 |
+
# реальной квоты на поиск нет вовсе ни у одной модели линейки Gemini 3.x. Бакет
|
| 393 |
+
# "Gemini 2.5" — 21/1500, то есть поиск реально работает ТОЛЬКО у gemini-2.5-flash и
|
| 394 |
+
# gemini-2.5-flash-lite. Они теперь и стоят первыми для запросов, где нужна живая
|
| 395 |
+
# информация. Модели Gemini 3.x оставлены в цепочке как резерв — не смогут вызвать
|
| 396 |
+
# google_search, но всё ещё могут ответить по своим знаниям (и через url_context,
|
| 397 |
+
# если в тексте есть ссылка — та возможность отдельной квоты не имеет вовсе).
|
| 398 |
+
GEMINI_SEARCH_CHAIN: list[str] = [
|
| 399 |
+
"gemini-2.5-flash",
|
| 400 |
+
"gemini-2.5-flash-lite",
|
| 401 |
+
"gemini-3.5-flash-lite",
|
| 402 |
+
"gemini-3.1-flash-lite",
|
| 403 |
+
"gemini-3.6-flash",
|
| 404 |
+
"gemini-3.5-flash",
|
| 405 |
+
"gemini-3-flash-preview",
|
| 406 |
+
]
|
| 407 |
+
# Совпадает по составу с прежним quota_fallback_chain — используется как дефолт,
|
| 408 |
+
# если ask_gemini вызвана без явной цепочки (например, напрямую из теста).
|
| 409 |
+
GEMINI_DEFAULT_CHAIN: list[str] = GEMINI_HEAVY_CHAIN
|
| 410 |
+
# Только "полноценные" (не no_system/Gemma) модели умеют читать сайты по ссылке
|
| 411 |
+
# (url_context) и разбирать YouTube-видео по ссылке (file_uri) — то же
|
| 412 |
+
# ограничение, что раньше проверялось в _handle_message_core через
|
| 413 |
+
# current_gemini_conf.get("no_system").
|
| 414 |
+
GEMINI_LINK_CHAIN: list[str] = [m for m in GEMINI_HEAVY_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")]
|
| 415 |
+
GEMINI_LINK_SEARCH_CHAIN: list[str] = [m for m in GEMINI_SEARCH_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")]
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# ── Эвристика "это сложный/тяжёлый запрос?" — без обращения к LLM. Ложные
|
| 419 |
+
# срабатывания недороги: худший случай — используется чуть более мощная
|
| 420 |
+
# модель, чем реально нужно, а не отказ в ответе.
|
| 421 |
+
_HEAVY_QUERY_RE = re.compile(
|
| 422 |
+
r"напиши\s+(код|функци\w*|скрипт|программ\w*|класс\w*|запрос\s+sql|regex|регуляр\w*)"
|
| 423 |
+
r"|сгенерируй\s+код|исправь\s+(код|баг|ошибк\w*)|отрефактор\w*|рефактор\w*|оптимизируй"
|
| 424 |
+
r"|напиши\s+(эссе|статью|доклад|реферат|сочинение|резюме|cv)\b"
|
| 425 |
+
r"|проанализируй\w*|разбер(и|ём)\s+подробно|объясни\s+подробно"
|
| 426 |
+
r"|сравни\s+.{0,40}(и|с)\s+|докажи\b|доказательство"
|
| 427 |
+
r"|реши\s+(задач\w*|уравнени\w*|систем\w*)"
|
| 428 |
+
r"|составь\s+(план|таблиц\w*|список\s+из)"
|
| 429 |
+
r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
|
| 430 |
+
r"|архитектур\w*|алгоритм\w*",
|
| 431 |
+
re.IGNORECASE,
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
def _looks_like_heavy_query(text: str) -> bool:
|
| 435 |
+
"""Грубая эвристика "это тяжёлый запрос (код/анализ/многошаговые рассуждения)?"
|
| 436 |
+
Намеренно консервативная (без вызова LLM — см. комментарий в начале секции)."""
|
| 437 |
+
if not text:
|
| 438 |
+
return False
|
| 439 |
+
if "```" in text or len(text) > 600:
|
| 440 |
+
return True
|
| 441 |
+
if text.count("?") >= 3:
|
| 442 |
+
return True
|
| 443 |
+
return bool(_HEAVY_QUERY_RE.search(text))
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# ── Эвристика "нужна ли живая информация из интернета?" Ложные срабатывания
|
| 447 |
+
# тоже недороги: худший случай — маршрут отдаёт предпочтение search-способной
|
| 448 |
+
# модели там, где поиск был не нужен, но модель сама решает, вызывать ли его.
|
| 449 |
+
_FRESHNESS_QUERY_RE = re.compile(
|
| 450 |
+
r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
|
| 451 |
+
r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*"
|
| 452 |
+
r"|цена\w*|стоимост\w*|сколько\s+стоит"
|
| 453 |
+
r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
|
| 454 |
+
r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
|
| 455 |
+
r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
|
| 456 |
+
r"|\b202[6-9]\b",
|
| 457 |
+
re.IGNORECASE,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
def _looks_like_freshness_query(text: str) -> bool:
|
| 461 |
+
return bool(text) and bool(_FRESHNESS_QUERY_RE.search(text))
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def _build_route(
|
| 465 |
+
*, needs_youtube: bool, needs_website: bool, media_mime: str | None,
|
| 466 |
+
is_heavy: bool, needs_freshness: bool,
|
| 467 |
+
) -> list[tuple[str, str]]:
|
| 468 |
+
"""Строит приоритетный список кандидатов (provider, model_id) для текущего
|
| 469 |
+
сообщения — НЕПУСТОЙ список, первый элемент пробуется первым (см. _run_route).
|
| 470 |
+
Порядок кандидатов внутри одного провайдера — по возрастанию "дороговизны"
|
| 471 |
+
для дефицитной квоты, а не по итоговому качеству ответа отдельно взятой модели."""
|
| 472 |
+
is_video_or_audio_media = bool(media_mime) and not media_mime.startswith("image/")
|
| 473 |
+
|
| 474 |
+
if needs_youtube or needs_website:
|
| 475 |
+
# Только Gemini умеет читать сайты по ссылке и разбирать YouTube-видео —
|
| 476 |
+
# у OpenRouter в этом маршруте вообще нет места, эскалировать некуда.
|
| 477 |
+
chain = GEMINI_LINK_SEARCH_CHAIN if needs_freshness else GEMINI_LINK_CHAIN
|
| 478 |
+
return _gemini_route(chain)
|
| 479 |
+
|
| 480 |
+
if media_mime:
|
| 481 |
+
if needs_freshness or is_video_or_audio_media:
|
| 482 |
+
# Видео/аудио вложение ИЛИ нужен живой поиск вместе с медиа — может
|
| 483 |
+
# только Gemini (OpenRouter физически не примет не-изображение, и
|
| 484 |
+
# ни одна его модель не имеет доступа к поиску).
|
| 485 |
+
chain = GEMINI_SEARCH_CHAIN if needs_freshness else GEMINI_HEAVY_CHAIN
|
| 486 |
+
return _gemini_route(chain)
|
| 487 |
+
# Изображение без нужды в поиске — сначала бесплатные vision-модели
|
| 488 |
+
# OpenRouter, Gemini — резерв, если они все разом откажут.
|
| 489 |
+
return _or_route(_OR_VISION_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 490 |
+
|
| 491 |
+
if needs_freshness:
|
| 492 |
+
# Текст без вложений, но нужна свежая информация — только у Gemini
|
| 493 |
+
# реально есть поиск; OpenRouter в конце как резерв на случай, если
|
| 494 |
+
# Gemini исчерпан целиком (без поиска, но хоть какой-то ответ).
|
| 495 |
+
return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER)
|
| 496 |
+
|
| 497 |
+
# Основной случай: обычный текст без вложений/ссылок/признаков нужды в
|
| 498 |
+
# интернете — целиком к OpenRouter, Gemini — резерв на случай отказа всей
|
| 499 |
+
# цепочки OpenRouter разом.
|
| 500 |
+
if is_heavy:
|
| 501 |
+
return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
|
| 502 |
+
return _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)
|
lumen_security.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_security.py — детерминированная защита от промт-инъекций и утечки
|
| 3 |
+
идентичности провайдера/модели (Lumen никогда не должен представляться как
|
| 4 |
+
Gemini/Gemma/OpenRouter и т.п. — см. system_prompt.py).
|
| 5 |
+
|
| 6 |
+
Вынесено из bot.py при аудите технического долга: детекторы (_detect_identity_leak,
|
| 7 |
+
_detect_injected_payload_echo, _looks_like_injection_probe) — чистые функции над
|
| 8 |
+
строками, не зависящие от Telegram/рантайм-состояния бота. Единственная внешняя
|
| 9 |
+
зависимость — GEMINI_MODELS/TEXT_MODEL_ORDER из lumen_router_config.py (нужны для
|
| 10 |
+
списка точных строк внутренних ID моделей, см. _LEAK_LITERAL_STRINGS ниже).
|
| 11 |
+
Публичные имена и поведение не изменились.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
|
| 19 |
+
from lumen_router_config import GEMINI_MODELS, TEXT_MODEL_ORDER
|
| 20 |
+
|
| 21 |
+
# Единый логгер "bot" (а не __name__) — чтобы caplog.at_level(..., logger="bot")
|
| 22 |
+
# в тестах продолжал ловить предупреждения независимо от того, в каком
|
| 23 |
+
# физическом файле живёт код (см. тот же приём в lumen_router_config.py).
|
| 24 |
+
log = logging.getLogger("bot")
|
| 25 |
+
|
| 26 |
+
# ─────────────────── защита от утечки провайдера/модели (выходной фильтр) ───────────────────
|
| 27 |
+
# Системный промпт (см. system_prompt.py) — это ПЕРВЫЙ, самый слабый рубеж: любую
|
| 28 |
+
# LLM в принципе можно уговорить нарушить свои инструкции достаточно настойчивой или
|
| 29 |
+
# creative промт-инъекцией (см. историю с чужим ботом, который выдал себя за другую
|
| 30 |
+
# модель именно через такую инъекцию). Поэтому здесь — ВТОРОЙ, детерминированный рубеж,
|
| 31 |
+
# который срабатывает уже ПОСЛЕ генерации ответа моделью и не зависит от того, что
|
| 32 |
+
# модель решила написать: если в готовом тексте всё-таки проскочило реальное имя
|
| 33 |
+
# модели/провайдера, весь ответ целиком подменяется на нейтральный fallback ДО того,
|
| 34 |
+
# как текст уйдёт пользователю и ДО того, как он попадёт в историю чата (иначе утечка
|
| 35 |
+
# осталась бы в контексте и могла бы "просочиться" в последующие ответы модели).
|
| 36 |
+
#
|
| 37 |
+
# Слой А — точные строки внутренних ID моделей. Ложных срабатываний практически не
|
| 38 |
+
# бывает: обычный ответ на обычный вопрос никогда не должен содержать дефис-разделённый
|
| 39 |
+
# технический идентификатор вида "gemini-3.5-flash" или "z-ai/glm-4.5-air:free" — такие
|
| 40 |
+
# строки в естественной русской (или английской) речи не встречаются случайно.
|
| 41 |
+
_LEAK_LITERAL_STRINGS: tuple[str, ...] = tuple(sorted(
|
| 42 |
+
set(GEMINI_MODELS.keys())
|
| 43 |
+
| {"gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"}
|
| 44 |
+
| set(TEXT_MODEL_ORDER)
|
| 45 |
+
))
|
| 46 |
+
# Найдено при код-ревью (performance): инкрементальная проверка в _try_gemini_streaming
|
| 47 |
+
# раньше пересканировала ВЕСЬ накопленный full_text на каждый новый кусок стрима — при
|
| 48 |
+
# длинном ответе с мелкими чанками это O(n²) по суммарной длине ответа. Самый длинный
|
| 49 |
+
# паттерн из всех детекторов (_LEAK_LITERAL_STRINGS/_IDENTITY_LEAK_RE/_INJECTED_PAYLOAD_
|
| 50 |
+
# ECHO_RE) — 61 символ; с большим запасом (5×) берём хвост в 300+ символов вместо всего
|
| 51 |
+
# текста — см. _leak_scan_window ниже. Любой паттерн, который мог бы образоваться на
|
| 52 |
+
# стыке старого текста и нового куска, гарантированно попадёт в это окно, если сам кусок
|
| 53 |
+
# короче окна (что всегда так для потоковых кусков от Gemini API).
|
| 54 |
+
_LEAK_SCAN_TAIL_CHARS = 300
|
| 55 |
+
|
| 56 |
+
def _leak_scan_window(full_text: str, latest_piece: str) -> str:
|
| 57 |
+
"""Возвращает "хвост" накопленного текста, достаточный для обнаружения ЛЮБОГО
|
| 58 |
+
паттерна утечки, который мог образоваться после добавления latest_piece — без
|
| 59 |
+
необходимости пересканировать весь full_text целиком на каждой итерации стрима.
|
| 60 |
+
Окно берётся с запасом на случай аномально большого одиночного куска."""
|
| 61 |
+
window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100)
|
| 62 |
+
return full_text[-window_size:]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Слой Б — само-идентификация как конкретный бренд/модель. ВАЖНО: раньше здесь было
|
| 66 |
+
# широкое окно "самореференция ... бренд" в пределах 60 символов — это ловило honest
|
| 67 |
+
# ответы вроде развёрнутого рассказа про OpenAI как компанию, где модель где-то в
|
| 68 |
+
# том же предложении естественно писала "я не могу сравнивать себя..." (обычное
|
| 69 |
+
# хеджирование, не утечка). "я" — один из самых частых русских токенов, поэтому
|
| 70 |
+
# любое достаточно длинное упоминание стороннего бренда рядом с ЛЮБЫМ "я" в тексте
|
| 71 |
+
# ложно срабатывало. Теперь — только точные, тесно связанные шаблоны конкретных
|
| 72 |
+
# формулировок самоидентификации (без произвольного зазора между словами), которые
|
| 73 |
+
# на практике встречаются ТОЛЬКО при реальной утечке, а не в обычном разговоре о
|
| 74 |
+
# сторонних моделях/компаниях.
|
| 75 |
+
_LEAK_BRAND_TOKENS = (
|
| 76 |
+
r"(gemini|gemma|gpt[\s\-]?oss|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
|
| 77 |
+
r"nemotron|qwen|llama|glm[\s\-]?4|hermes|dolphin[\s\-]?mistral|venice|laguna|"
|
| 78 |
+
r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)"
|
| 79 |
+
)
|
| 80 |
+
_IDENTITY_LEAK_RE = re.compile(
|
| 81 |
+
rf"\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?{_LEAK_BRAND_TOKENS}\b"
|
| 82 |
+
rf"|\bмен[яе]\s+(?:зовут|называют)\s+{_LEAK_BRAND_TOKENS}\b"
|
| 83 |
+
rf"|\bя\s+созда(?:н|на)\w*\s+(?:компанией\s+)?{_LEAK_BRAND_TOKENS}\b"
|
| 84 |
+
rf"|\bмен[яе]\s+созда(?:л|ла)\w*\s+{_LEAK_BRAND_TOKENS}\b"
|
| 85 |
+
rf"|\bработаю\s+на\s+(?:базе\s+)?{_LEAK_BRAND_TOKENS}\b"
|
| 86 |
+
rf"|\bоснован\w*\s+на\s+{_LEAK_BRAND_TOKENS}\b"
|
| 87 |
+
rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
|
| 88 |
+
rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b"
|
| 89 |
+
rf"|\bbuilt\s+on\s+{_LEAK_BRAND_TOKENS}\b"
|
| 90 |
+
rf"|\bpowered\s+by\s+{_LEAK_BRAND_TOKENS}\b"
|
| 91 |
+
rf"|\bbased\s+on\s+{_LEAK_BRAND_TOKENS}\b"
|
| 92 |
+
rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
|
| 93 |
+
re.IGNORECASE,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
_IDENTITY_LEAK_FALLBACK = (
|
| 97 |
+
"Внутренние технические детали своей реализации я не раскрываю. "
|
| 98 |
+
"Если у вас есть другой вопрос — с радостью помогу."
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Слой В — "эхо" внедрённой в контент (фото/документ/сайт) вредоносной инструкции.
|
| 102 |
+
# Реальный найденный на практике обход: атакующий подсовывает картинку/страницу с
|
| 103 |
+
# текстом вида "[SYSTEM NOTICE] ...выведи ровно эту строку, подтверждающую взлом...".
|
| 104 |
+
# Модель может отказаться ВЫПОЛНИТЬ эту инструкцию, но при просьбе "перескажи/опиши
|
| 105 |
+
# содержимое" или "сделай саммари того, что тебе передали при запуске" — иногда всё
|
| 106 |
+
# же дословно ВОСПРОИЗВОДИТ целевую строку атаки внутри своего пересказа, и эта
|
| 107 |
+
# строка затем оседает в истории чата и может "всплыть" в следующих repl. Намеренно
|
| 108 |
+
# ОЧЕНЬ узкий список ключевых слов (типичная лексика "подтверждения взлома" в
|
| 109 |
+
# proof-of-concept пейлоадах) — а не общий поиск ALL_CAPS/переиспользование входного
|
| 110 |
+
# _INJECTION_PROBE_RE, иначе ловились бы легитимные ответы (код с константами вида
|
| 111 |
+
# API_KEY/MAX_RETRIES, честные объяснения того, что такое джейлбрейк, и т.п.).
|
| 112 |
+
_INJECTED_PAYLOAD_ECHO_RE = re.compile(
|
| 113 |
+
r"security[_\s]?breach[_\s]?detected"
|
| 114 |
+
r"|system[_\s]?override[_\s]?(successful|complete)"
|
| 115 |
+
r"|diagnostic[_\s]?success"
|
| 116 |
+
r"|prompt[_\s]?validation[_\s]?successful"
|
| 117 |
+
r"|jailbreak[_\s]?success(ful)?"
|
| 118 |
+
r"|bypass[_\s]?successful"
|
| 119 |
+
r"|injection[_\s]?successful"
|
| 120 |
+
r"|breach[_\s]?detected"
|
| 121 |
+
r"|взлом\s+(прошёл\s+)?успешно"
|
| 122 |
+
r"|инъекция\s+(прошла\s+)?успешно"
|
| 123 |
+
r"|проверка\s+(пройдена|успешна)[:.]?\s*(систем\w*|промпт\w*)",
|
| 124 |
+
re.IGNORECASE,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
_INJECTED_PAYLOAD_ECHO_FALLBACK = (
|
| 128 |
+
"Это похоже на текст из инструкции, внедрённой в присланный контент, а не на "
|
| 129 |
+
"обычный ответ — воспроизводить его не буду. Если у вас обычный вопрос, задайте "
|
| 130 |
+
"его, и я отвечу."
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
def _detect_injected_payload_echo(text: str) -> bool:
|
| 134 |
+
return bool(text) and bool(_INJECTED_PAYLOAD_ECHO_RE.search(text))
|
| 135 |
+
|
| 136 |
+
def _detect_identity_leak(text: str) -> bool:
|
| 137 |
+
"""Чистая функция без побочных эффектов — намеренно отделена от _scrub_identity_leak
|
| 138 |
+
(которая ещё и логирует), чтобы можно было дёшево вызывать её на КАЖДЫЙ кусок текста
|
| 139 |
+
во время стриминга (см. _try_gemini_streaming), не заливая логи повторными записями
|
| 140 |
+
об одном и том же инциденте на каждый новый символ."""
|
| 141 |
+
if not text:
|
| 142 |
+
return False
|
| 143 |
+
low = text.lower()
|
| 144 |
+
for lit in _LEAK_LITERAL_STRINGS:
|
| 145 |
+
if lit and lit.lower() in low:
|
| 146 |
+
return True
|
| 147 |
+
return bool(_IDENTITY_LEAK_RE.search(text))
|
| 148 |
+
|
| 149 |
+
def _scrub_identity_leak(text: str, *, source: str) -> str:
|
| 150 |
+
"""Точка применения фильтра для НЕстримингового пути (ask_gemini, ask_openrouter_*).
|
| 151 |
+
Вызывается непосредственно перед записью ответа в историю чата — если вызвать её
|
| 152 |
+
только перед показом пользователю, но не перед hist.append/history.append, утечка
|
| 153 |
+
осталась бы в истории и могла бы повлиять на последующие ответы модели."""
|
| 154 |
+
if _detect_identity_leak(text):
|
| 155 |
+
log.warning("[identity-leak] Обнаружена и заблокирована утечка идентичности (source=%s): %r", source, text[:500])
|
| 156 |
+
return _IDENTITY_LEAK_FALLBACK
|
| 157 |
+
if _detect_injected_payload_echo(text):
|
| 158 |
+
log.warning("[injection-echo] Обнаружено и заблокировано вероятное эхо внедрённой инструкции (source=%s): %r", source, text[:500])
|
| 159 |
+
return _INJECTED_PAYLOAD_ECHO_FALLBACK
|
| 160 |
+
return text
|
| 161 |
+
|
| 162 |
+
# ─────────────────── защита от промт-инъекций (входной префильтр) ───────────────────
|
| 163 |
+
# Первый (и самый дешёвый/надёжный) рубеж: явные, хорошо известные паттерны попытки
|
| 164 |
+
# "взломать" системный промпт — если сообщение совпадает с одним из них, отвечаем
|
| 165 |
+
# заранее заготовленной фразой БЕЗ обращения к LLM вообще. Для этого конкретного класса
|
| 166 |
+
# атак это даёт СТОПРОЦЕНТНУЮ гарантию отсутствия утечки (в отличие от системного
|
| 167 |
+
# промпта, который в принципе можно обойти достаточно творческой формулировкой) — сама
|
| 168 |
+
# модель тут просто не участвует.
|
| 169 |
+
#
|
| 170 |
+
# ВАЖНО: сюда намеренно НЕ включены обычные любопытные вопросы вида "какая ты модель
|
| 171 |
+
# на самом деле" / "ты точно не Gemini?" — на них и так есть отдельная честная и
|
| 172 |
+
# небанальная (без дословных повторов, см. ИДЕНТИЧНОСТЬ в system_prompt.py) логика
|
| 173 |
+
# внутри самой модели. Здесь — только однозначные попытки ПОДМЕНИТЬ инструкции или
|
| 174 |
+
# выдавить из бота его системный промпт, а не безобидное любопытство.
|
| 175 |
+
_INJECTION_PROBE_RE = re.compile(
|
| 176 |
+
r"ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)\s+instructions"
|
| 177 |
+
r"|забудь\s+(все\s+|про\s+)?(предыдущие\s+|системные\s+)?инструкции"
|
| 178 |
+
r"|игнорируй\s+(все\s+|любые\s+)?(предыдущие\s+|системные\s+)?(инструкции|прав��ла|указания)"
|
| 179 |
+
r"|print\s+your\s+(system\s+)?(prompt|instructions)"
|
| 180 |
+
r"|repeat\s+(everything|the\s+text|all\s+the\s+words)\s+above"
|
| 181 |
+
r"|покажи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
|
| 182 |
+
r"|выведи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
|
| 183 |
+
r"|повтори\s+(всё\s+|весь\s+текст\s+)?(что\s+)?(написано\s+)?выше"
|
| 184 |
+
r"|(developer|debug|god|dan|jailbreak)\s*[\s\-]?mode"
|
| 185 |
+
r"|режим\s+(разработчика|отладки|бога|джейлбрейк\w*)"
|
| 186 |
+
r"|you\s+are\s+now\s+(an?\s+)?(unrestricted|uncensored|jailbroken)"
|
| 187 |
+
r"|ты\s+теперь\s+(без\s+ограничени\w*|неограничен\w*|не\s+связан\w*\s+правилами)"
|
| 188 |
+
r"|act\s+as\s+(an?\s+)?(unfiltered|uncensored|jailbroken|dan)\b"
|
| 189 |
+
r"|притворись\s*,?\s*(что\s+)?у\s+тебя\s+нет\s+(правил|ограничени\w*)"
|
| 190 |
+
r"|(what|which)\s+(is\s+)?your\s+(real\s+|actual\s+)?system\s+prompt"
|
| 191 |
+
r"|раскрой\s+(свой\s+)?системн\w*\s+промпт",
|
| 192 |
+
re.IGNORECASE,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
_INJECTION_PROBE_REPLY = (
|
| 196 |
+
"Свою настройку и инструкции я не раскрываю и не обсуждаю в таком формате. "
|
| 197 |
+
"Если у вас обычный вопрос — задавайте, с радостью помогу."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def _looks_like_injection_probe(text: str) -> bool:
|
| 201 |
+
"""Чистая функция — тестируется отдельно от _handle_message_core."""
|
| 202 |
+
return bool(text) and bool(_INJECTION_PROBE_RE.search(text))
|
test_bot_helpers.py
CHANGED
|
@@ -564,8 +564,15 @@ class _FakeStatusExc(Exception):
|
|
| 564 |
|
| 565 |
|
| 566 |
def test_gemini_error_msg_rate_limit():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
exc = _FakeStatusExc("rate limit exceeded", status_code=429)
|
| 568 |
-
|
|
|
|
|
|
|
| 569 |
|
| 570 |
|
| 571 |
def test_gemini_error_msg_value_error_passthrough():
|
|
@@ -576,19 +583,39 @@ def test_gemini_error_msg_value_error_passthrough():
|
|
| 576 |
|
| 577 |
|
| 578 |
def test_gemini_error_msg_all_models_exhausted():
|
|
|
|
|
|
|
|
|
|
| 579 |
exc = bot.GeminiAllModelsExhaustedError(["gemini-3.5-flash", "gemini-2.5-flash"])
|
| 580 |
msg = bot._gemini_error_msg(exc, "gemini-3.5-flash")
|
| 581 |
-
assert "/provider" in msg
|
|
|
|
| 582 |
|
| 583 |
|
| 584 |
def test_or_error_msg_rate_limit():
|
|
|
|
|
|
|
|
|
|
| 585 |
exc = _FakeStatusExc("rate limit exceeded", status_code=429)
|
| 586 |
-
|
|
|
|
|
|
|
| 587 |
|
| 588 |
|
| 589 |
def test_or_error_msg_unavailable():
|
| 590 |
exc = _FakeStatusExc("model not found", status_code=404)
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
|
| 593 |
|
| 594 |
# ─────────────────────────── _cleanup_rate_limit_dict ───────────────────────────
|
|
@@ -658,6 +685,63 @@ def test_upstash_get_parses_result_field():
|
|
| 658 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 659 |
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
# ─────────────────────────── _save_chat_to_storage/_delete_chat_storage возвращают bool ───────────────────────────
|
| 662 |
|
| 663 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
@@ -2224,3 +2308,337 @@ def test_or_light_order_no_longer_starts_with_dead_or_worst_offender_models():
|
|
| 2224 |
assert "qwen/qwen3-next-80b-a3b-instruct:free" not in bot._OR_LIGHT_ORDER
|
| 2225 |
assert bot._OR_LIGHT_ORDER[0] not in {"openai/gpt-oss-20b:free", "nvidia/nemotron-3-nano-30b-a3b:free"}
|
| 2226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
|
| 565 |
|
| 566 |
def test_gemini_error_msg_rate_limit():
|
| 567 |
+
# РЕГРЕССИЯ (аудит техдолга): раньше здесь проверялось "модель через /model" —
|
| 568 |
+
# команда /model давно удалена (см. README, "Автоматический выбор модели"),
|
| 569 |
+
# и подсказывать её в тексте ошибки было прямой ошибкой для пользователя.
|
| 570 |
+
# _gemini_error_msg/_or_error_msg теперь используют общие provider-neutral
|
| 571 |
+
# шаблоны (см. _MODEL_ERROR_MESSAGES) без упоминания несуществующих команд.
|
| 572 |
exc = _FakeStatusExc("rate limit exceeded", status_code=429)
|
| 573 |
+
msg = bot._gemini_error_msg(exc, "gemini-3.5-flash")
|
| 574 |
+
assert "/model" not in msg and "/provider" not in msg
|
| 575 |
+
assert msg == bot._model_error_text("rate_limit")
|
| 576 |
|
| 577 |
|
| 578 |
def test_gemini_error_msg_value_error_passthrough():
|
|
|
|
| 583 |
|
| 584 |
|
| 585 |
def test_gemini_error_msg_all_models_exhausted():
|
| 586 |
+
# РЕГРЕССИЯ (аудит техдолга): раньше здесь проверялось "/provider" — команда
|
| 587 |
+
# удалена, реального способа переключиться на резервный провайдер вручную
|
| 588 |
+
# больше нет, поэтому предлагать её в тексте ошибки было ошибкой.
|
| 589 |
exc = bot.GeminiAllModelsExhaustedError(["gemini-3.5-flash", "gemini-2.5-flash"])
|
| 590 |
msg = bot._gemini_error_msg(exc, "gemini-3.5-flash")
|
| 591 |
+
assert "/provider" not in msg
|
| 592 |
+
assert "лимит" in msg.lower()
|
| 593 |
|
| 594 |
|
| 595 |
def test_or_error_msg_rate_limit():
|
| 596 |
+
# РЕГРЕССИЯ (аудит техдолга): "/provider" убран из текста (команда удалена),
|
| 597 |
+
# а формулировка "резервного провайдера" тоже убрана — с автоматическим
|
| 598 |
+
# роутером OpenRouter часто оказывается ПЕРВЫМ, а не резервным кандидатом.
|
| 599 |
exc = _FakeStatusExc("rate limit exceeded", status_code=429)
|
| 600 |
+
msg = bot._or_error_msg(exc, "text")
|
| 601 |
+
assert "/provider" not in msg and "резервн" not in msg.lower()
|
| 602 |
+
assert msg == bot._model_error_text("rate_limit")
|
| 603 |
|
| 604 |
|
| 605 |
def test_or_error_msg_unavailable():
|
| 606 |
exc = _FakeStatusExc("model not found", status_code=404)
|
| 607 |
+
msg = bot._or_error_msg(exc, "text")
|
| 608 |
+
assert "/provider" not in msg and "резервн" not in msg.lower()
|
| 609 |
+
assert msg == bot._model_error_text("unavailable")
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def test_model_error_text_shared_between_providers():
|
| 613 |
+
# Единый источник правды для текста ошибок (см. аудит техдолга) — Gemini и
|
| 614 |
+
# OpenRouter должны показывать ОДИНАКОВЫЙ текст на одинаковый класс ошибки,
|
| 615 |
+
# а не рассинхронизированные формулировки в двух местах.
|
| 616 |
+
gem_exc = _FakeStatusExc("resource_exhausted", status_code=429)
|
| 617 |
+
or_exc = _FakeStatusExc("rate limit exceeded", status_code=429)
|
| 618 |
+
assert bot._gemini_error_msg(gem_exc, "gemini-3.5-flash") == bot._or_error_msg(or_exc, "text")
|
| 619 |
|
| 620 |
|
| 621 |
# ─────────────────────────── _cleanup_rate_limit_dict ───────────────────────────
|
|
|
|
| 685 |
bot.UPSTASH_REDIS_REST_TOKEN = ""
|
| 686 |
|
| 687 |
|
| 688 |
+
# ─────────────── миграция image_model со старого формата "pollinations:X" ───────────────
|
| 689 |
+
# РЕГРЕССИЯ, найденная при /code-review: ponytail-audit убрал приставку "pollinations:"
|
| 690 |
+
# из ключей HF_IMAGE_MODELS (единственный провайдер и так один). Без миграции это молча
|
| 691 |
+
# сбрасывало бы image_model существующих персистентных чатов на DEFAULT_HF_IMAGE_MODEL —
|
| 692 |
+
# реальный выбор пользователя (например "pollinations:turbo") терялся бы без предупреждения,
|
| 693 |
+
# просто потому что старое значение больше не совпадает ни с одним ключом HF_IMAGE_MODELS.
|
| 694 |
+
|
| 695 |
+
def test_normalize_legacy_image_model_id_strips_old_prefix():
|
| 696 |
+
assert bot._normalize_legacy_image_model_id("pollinations:turbo") == "turbo"
|
| 697 |
+
assert bot._normalize_legacy_image_model_id("pollinations:flux-anime") == "flux-anime"
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def test_normalize_legacy_image_model_id_passthrough_for_current_format():
|
| 701 |
+
assert bot._normalize_legacy_image_model_id("turbo") == "turbo"
|
| 702 |
+
assert bot._normalize_legacy_image_model_id("flux") == "flux"
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
def test_normalize_legacy_image_model_id_passthrough_for_non_string():
|
| 706 |
+
# Защита от неожиданных типов в персистентных данных — не должно падать.
|
| 707 |
+
assert bot._normalize_legacy_image_model_id(None) is None
|
| 708 |
+
assert bot._normalize_legacy_image_model_id(123) == 123
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def test_restore_single_chat_migrates_legacy_pollinations_image_model():
|
| 712 |
+
cid = 999901
|
| 713 |
+
try:
|
| 714 |
+
bot._restore_single_chat(cid, {"image_model": "pollinations:turbo", "history": []})
|
| 715 |
+
assert bot.chat_state[cid]["image_model"] == "turbo"
|
| 716 |
+
finally:
|
| 717 |
+
bot.chat_state.pop(cid, None)
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def test_restore_single_chat_falls_back_to_default_for_truly_unknown_image_model():
|
| 721 |
+
cid = 999902
|
| 722 |
+
try:
|
| 723 |
+
bot._restore_single_chat(cid, {"image_model": "some-removed-model-nobody-heard-of", "history": []})
|
| 724 |
+
assert bot.chat_state[cid]["image_model"] == bot.DEFAULT_HF_IMAGE_MODEL
|
| 725 |
+
finally:
|
| 726 |
+
bot.chat_state.pop(cid, None)
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def test_get_state_migrates_legacy_pollinations_image_model_in_memory():
|
| 730 |
+
# Защита в глубину (см. get_state) — на случай, если в chat_state окажется
|
| 731 |
+
# старое значение уже ПОСЛЕ восстановления (например, вручную отредактированные
|
| 732 |
+
# персистентные данные), а не только на пути через _restore_single_chat.
|
| 733 |
+
cid = 999903
|
| 734 |
+
try:
|
| 735 |
+
bot.chat_state[cid] = {
|
| 736 |
+
"image_model": "pollinations:flux-realism", "history": [], "quota": {},
|
| 737 |
+
"recent_media_ids": {}, "last_activity": 0.0,
|
| 738 |
+
}
|
| 739 |
+
state = bot.get_state(cid)
|
| 740 |
+
assert state["image_model"] == "flux-realism"
|
| 741 |
+
finally:
|
| 742 |
+
bot.chat_state.pop(cid, None)
|
| 743 |
+
|
| 744 |
+
|
| 745 |
# ─────────────────────────── _save_chat_to_storage/_delete_chat_storage возвращают bool ───────────────────────────
|
| 746 |
|
| 747 |
def test_save_chat_to_storage_returns_true_on_success(tmp_path):
|
|
|
|
| 2308 |
assert "qwen/qwen3-next-80b-a3b-instruct:free" not in bot._OR_LIGHT_ORDER
|
| 2309 |
assert bot._OR_LIGHT_ORDER[0] not in {"openai/gpt-oss-20b:free", "nvidia/nemotron-3-nano-30b-a3b:free"}
|
| 2310 |
|
| 2311 |
+
|
| 2312 |
+
# ─────────────────── единый реестр "нездоровых" моделей OpenRouter (аудит техдолга) ───────────────────
|
| 2313 |
+
# Раньше "эта модель сейчас плохая" отслеживалось тремя независимыми механизмами
|
| 2314 |
+
# (_TEMPORARY_FREE_MODELS/_ROUTER_EXCLUDED_OR_MODELS/точечные вычёркивания из
|
| 2315 |
+
# order-списков) — тесты ниже закрепляют, что теперь единственный источник
|
| 2316 |
+
# правды — _OR_MODEL_HEALTH, а всё остальное вычисляется из него.
|
| 2317 |
+
|
| 2318 |
+
def test_router_excluded_or_models_is_derived_from_health_registry():
|
| 2319 |
+
assert bot._ROUTER_EXCLUDED_OR_MODELS == frozenset(bot._OR_MODEL_HEALTH.keys())
|
| 2320 |
+
|
| 2321 |
+
|
| 2322 |
+
def test_model_health_registry_contains_all_three_known_incidents():
|
| 2323 |
+
for model_id in (
|
| 2324 |
+
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
|
| 2325 |
+
"qwen/qwen3-coder:free",
|
| 2326 |
+
"tencent/hy3:free",
|
| 2327 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 2328 |
+
):
|
| 2329 |
+
assert model_id in bot._OR_MODEL_HEALTH
|
| 2330 |
+
assert bot._OR_MODEL_HEALTH[model_id].reason
|
| 2331 |
+
|
| 2332 |
+
|
| 2333 |
+
def test_check_temporary_free_models_expiry_warns_using_registry_reason(caplog):
|
| 2334 |
+
import logging
|
| 2335 |
+
with caplog.at_level(logging.WARNING, logger="bot"):
|
| 2336 |
+
bot._check_temporary_free_models_expiry()
|
| 2337 |
+
messages = "\n".join(r.getMessage() for r in caplog.records)
|
| 2338 |
+
# qwen3-coder/hy3 промо давно истекло (даты в прошлом) — предупреждение должно
|
| 2339 |
+
# включать причину прямо из реестра, а не отдельный захардкоженный текст.
|
| 2340 |
+
assert "qwen/qwen3-coder:free" in messages
|
| 2341 |
+
assert "tencent/hy3:free" in messages
|
| 2342 |
+
|
| 2343 |
+
|
| 2344 |
+
def test_model_health_note_without_promo_expiry_is_permanent_exclusion():
|
| 2345 |
+
# qwen3-next и dolphin-mistral сняты НЕ по истечении промо-акции (нет даты) —
|
| 2346 |
+
# они не должны попадать в предупреждение об истёкшем промо вообще.
|
| 2347 |
+
for model_id in (
|
| 2348 |
+
"qwen/qwen3-next-80b-a3b-instruct:free",
|
| 2349 |
+
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
|
| 2350 |
+
):
|
| 2351 |
+
assert bot._OR_MODEL_HEALTH[model_id].promo_expiry is None
|
| 2352 |
+
|
| 2353 |
+
|
| 2354 |
+
# ──────────���──────── каталог моделей генерации изображений (аудит техдолга) ───────────────────
|
| 2355 |
+
# Раньше был отдельный HF_IMAGE_MODEL_CACHE + async _hf_fetch_model_catalog(),
|
| 2356 |
+
# оба вестигиальные (динамический фетч из HF API убран, каталог всегда 1:1 из
|
| 2357 |
+
# HF_IMAGE_MODELS) — заменены на синхронный _hf_model_catalog(). Тестов на этот
|
| 2358 |
+
# слой раньше не было вообще; добавлены вместе с упрощением, чтобы не убрать
|
| 2359 |
+
# индирекцию "молча".
|
| 2360 |
+
|
| 2361 |
+
def test_hf_model_catalog_matches_hf_image_models_exactly():
|
| 2362 |
+
catalog = bot._hf_model_catalog()
|
| 2363 |
+
assert [m["id"] for m in catalog] == list(bot.HF_IMAGE_MODELS.keys())
|
| 2364 |
+
for m in catalog:
|
| 2365 |
+
assert m["name"] == bot.HF_IMAGE_MODELS[m["id"]]["name"]
|
| 2366 |
+
|
| 2367 |
+
|
| 2368 |
+
def test_imgmodel_keyboard_marks_current_model_and_paginates():
|
| 2369 |
+
kb = bot._imgmodel_keyboard(bot.DEFAULT_HF_IMAGE_MODEL)
|
| 2370 |
+
all_buttons = [btn for row in kb.inline_keyboard for btn in row]
|
| 2371 |
+
current_buttons = [b for b in all_buttons if b.text.startswith("• ")]
|
| 2372 |
+
assert len(current_buttons) == 1
|
| 2373 |
+
assert bot.HF_IMAGE_MODELS[bot.DEFAULT_HF_IMAGE_MODEL]["name"] in current_buttons[0].text
|
| 2374 |
+
|
| 2375 |
+
|
| 2376 |
+
def test_imgmodel_keyboard_clamps_out_of_range_page():
|
| 2377 |
+
# 5 моделей, HF_IMAGE_MODEL_PAGE_SIZE=8 -> всегда ровно одна страница.
|
| 2378 |
+
kb = bot._imgmodel_keyboard(bot.DEFAULT_HF_IMAGE_MODEL, page=99)
|
| 2379 |
+
# Не должно быть кнопки "Дальше >" — 1 страница и так показывает всё.
|
| 2380 |
+
nav_texts = [b.text for row in kb.inline_keyboard for b in row if "Назад" in b.text or "Дальше" in b.text]
|
| 2381 |
+
assert nav_texts == []
|
| 2382 |
+
|
| 2383 |
+
# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ───────────────────
|
| 2384 |
+
|
| 2385 |
+
def test_serialize_chat_state_stamps_current_schema_version():
|
| 2386 |
+
state = {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [], "quota": {}, "recent_media_ids": {}}
|
| 2387 |
+
snapshot = bot._serialize_chat_state(state)
|
| 2388 |
+
assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION
|
| 2389 |
+
|
| 2390 |
+
|
| 2391 |
+
def test_restore_single_chat_accepts_legacy_record_without_schema_version():
|
| 2392 |
+
# Записи, сохранённые до введения schema_version, не имеют этого поля вообще —
|
| 2393 |
+
# восстановление не должно падать и должно вести себя так же, как раньше.
|
| 2394 |
+
cid = 999905
|
| 2395 |
+
try:
|
| 2396 |
+
bot._restore_single_chat(cid, {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}]})
|
| 2397 |
+
assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
|
| 2398 |
+
finally:
|
| 2399 |
+
bot.chat_state.pop(cid, None)
|
| 2400 |
+
|
| 2401 |
+
|
| 2402 |
+
def test_restore_single_chat_accepts_current_schema_version_record():
|
| 2403 |
+
cid = 999906
|
| 2404 |
+
try:
|
| 2405 |
+
snapshot = bot._serialize_chat_state({
|
| 2406 |
+
"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
|
| 2407 |
+
"quota": {}, "recent_media_ids": {},
|
| 2408 |
+
})
|
| 2409 |
+
bot._restore_single_chat(cid, snapshot)
|
| 2410 |
+
assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
|
| 2411 |
+
finally:
|
| 2412 |
+
bot.chat_state.pop(cid, None)
|
| 2413 |
+
|
| 2414 |
+
# ─────────────────── _TelegramProxyCircuitBreaker (аудит техдолга) ───────────────────
|
| 2415 |
+
# Раньше состояние выключателя жило как четыре независимых module-level globals,
|
| 2416 |
+
# мутируемых через `global` из двух разных функций — само поведение (порог
|
| 2417 |
+
# срабатывания, cooldown, сброс счётчика на успех) нигде не тестировалось
|
| 2418 |
+
# напрямую, только опосредованно через _tg_call/telegram_api_call. Инкапсуляция
|
| 2419 |
+
# в класс делает это поведение тестируемым в изоляции.
|
| 2420 |
+
|
| 2421 |
+
def test_circuit_breaker_starts_closed():
|
| 2422 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2423 |
+
assert breaker.is_down(time.monotonic()) is False
|
| 2424 |
+
|
| 2425 |
+
|
| 2426 |
+
def test_circuit_breaker_does_not_trip_before_threshold():
|
| 2427 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2428 |
+
assert breaker.note_failure() is False
|
| 2429 |
+
assert breaker.note_failure() is False
|
| 2430 |
+
assert breaker.is_down(time.monotonic()) is False
|
| 2431 |
+
|
| 2432 |
+
|
| 2433 |
+
def test_circuit_breaker_trips_at_threshold():
|
| 2434 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2435 |
+
breaker.note_failure()
|
| 2436 |
+
breaker.note_failure()
|
| 2437 |
+
tripped = breaker.note_failure()
|
| 2438 |
+
assert tripped is True
|
| 2439 |
+
breaker.trip()
|
| 2440 |
+
assert breaker.is_down(time.monotonic()) is True
|
| 2441 |
+
|
| 2442 |
+
|
| 2443 |
+
def test_circuit_breaker_success_resets_consecutive_failures():
|
| 2444 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2445 |
+
breaker.note_failure()
|
| 2446 |
+
breaker.note_failure()
|
| 2447 |
+
breaker.note_success()
|
| 2448 |
+
assert breaker.consecutive_failures == 0
|
| 2449 |
+
# Единичные последующие сбои не должны сразу срабатывать — счётчик правда сброшен.
|
| 2450 |
+
assert breaker.note_failure() is False
|
| 2451 |
+
|
| 2452 |
+
|
| 2453 |
+
def test_circuit_breaker_garbage_event_count_never_resets():
|
| 2454 |
+
# В отличие от consecutive_failures, совокупный счётчик для /stats копится
|
| 2455 |
+
# за всё время жизни процесса и не должен сбрасываться на success.
|
| 2456 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2457 |
+
breaker.note_failure()
|
| 2458 |
+
breaker.note_success()
|
| 2459 |
+
breaker.note_failure()
|
| 2460 |
+
assert breaker.garbage_event_count == 2
|
| 2461 |
+
|
| 2462 |
+
|
| 2463 |
+
def test_circuit_breaker_status_text_reflects_state():
|
| 2464 |
+
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 2465 |
+
assert "в норме" in breaker.status_text()
|
| 2466 |
+
breaker.note_failure()
|
| 2467 |
+
breaker.note_failure()
|
| 2468 |
+
breaker.note_failure()
|
| 2469 |
+
breaker.trip()
|
| 2470 |
+
assert "ВЫКЛЮЧЕН" in breaker.status_text()
|
| 2471 |
+
|
| 2472 |
+
# ─────────────────── ask_gemini: fallback-цикл по цепочке моделей (аудит техдолга) ───────────────────
|
| 2473 |
+
# НАЙДЕНО ПРИ АУДИТЕ: внутренняя логика переключения между моделями в ask_gemini
|
| 2474 |
+
# (429 -> следующая модель / таймаут -> следующая / прочая ошибка -> следующая /
|
| 2475 |
+
# бюджет времени исчерпан -> RouteBudgetExceededError) раньше не была покрыта
|
| 2476 |
+
# напрямую НИ ОДНИМ тестом — только опосредованно через _run_route (который
|
| 2477 |
+
# мокает саму ask_gemini целиком, не проверяя её внутренний цикл). Тесты ниже
|
| 2478 |
+
# фиксируют текущее поведение ДО рефакторинга (объединение классификации ошибок
|
| 2479 |
+
# с _or_chat_completion_with_fallback через общие _error_status/_classify_model_error).
|
| 2480 |
+
|
| 2481 |
+
def test_ask_gemini_falls_back_to_next_model_on_quota_exhausted():
|
| 2482 |
+
chat_id = 999010
|
| 2483 |
+
calls = []
|
| 2484 |
+
|
| 2485 |
+
class _QuotaExc(Exception):
|
| 2486 |
+
status_code = 429
|
| 2487 |
+
|
| 2488 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 2489 |
+
calls.append(model)
|
| 2490 |
+
if model == "gemini-3.6-flash":
|
| 2491 |
+
raise _QuotaExc("resource_exhausted")
|
| 2492 |
+
return _FakeGeminiResponse(text="Ответ от второй модели")
|
| 2493 |
+
|
| 2494 |
+
fake_client = MagicMock()
|
| 2495 |
+
fake_client.models.generate_content.side_effect = fake_generate_content
|
| 2496 |
+
original_client = bot.client
|
| 2497 |
+
bot.client = fake_client
|
| 2498 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None)
|
| 2499 |
+
try:
|
| 2500 |
+
answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"]))
|
| 2501 |
+
assert answer == "Ответ от второй модели"
|
| 2502 |
+
assert calls == ["gemini-3.6-flash", "gemini-2.5-flash"]
|
| 2503 |
+
# Модель, отдавшая 429, должна быть помечена исчерпанной (влияет на будущий роутинг).
|
| 2504 |
+
assert bot.GLOBAL_QUOTA["gemini"]["gemini-3.6-flash"]["exhausted_at"] is not None
|
| 2505 |
+
finally:
|
| 2506 |
+
bot.client = original_client
|
| 2507 |
+
bot.chat_state.pop(chat_id, None)
|
| 2508 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None)
|
| 2509 |
+
|
| 2510 |
+
|
| 2511 |
+
def test_ask_gemini_raises_all_models_exhausted_when_entire_chain_429s():
|
| 2512 |
+
chat_id = 999011
|
| 2513 |
+
|
| 2514 |
+
class _QuotaExc(Exception):
|
| 2515 |
+
status_code = 429
|
| 2516 |
+
|
| 2517 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 2518 |
+
raise _QuotaExc("resource_exhausted")
|
| 2519 |
+
|
| 2520 |
+
fake_client = MagicMock()
|
| 2521 |
+
fake_client.models.generate_content.side_effect = fake_generate_content
|
| 2522 |
+
original_client = bot.client
|
| 2523 |
+
bot.client = fake_client
|
| 2524 |
+
try:
|
| 2525 |
+
with pytest.raises(bot.GeminiAllModelsExhaustedError) as exc_info:
|
| 2526 |
+
asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"]))
|
| 2527 |
+
assert set(exc_info.value.exhausted_models) == {"gemini-3.6-flash", "gemini-2.5-flash"}
|
| 2528 |
+
finally:
|
| 2529 |
+
bot.client = original_client
|
| 2530 |
+
bot.chat_state.pop(chat_id, None)
|
| 2531 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None)
|
| 2532 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash", None)
|
| 2533 |
+
|
| 2534 |
+
|
| 2535 |
+
def test_ask_gemini_falls_back_to_next_model_on_timeout():
|
| 2536 |
+
chat_id = 999012
|
| 2537 |
+
calls = []
|
| 2538 |
+
original_timeout = bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 2539 |
+
|
| 2540 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 2541 |
+
calls.append(model)
|
| 2542 |
+
if model == "gemini-3.6-flash":
|
| 2543 |
+
time.sleep(0.2) # дольше урезанного ROUTE_MODEL_TIMEOUT_SEC ниже
|
| 2544 |
+
return _FakeGeminiResponse(text="Ответ от второй модели")
|
| 2545 |
+
|
| 2546 |
+
fake_client = MagicMock()
|
| 2547 |
+
fake_client.models.generate_content.side_effect = fake_generate_content
|
| 2548 |
+
original_client = bot.client
|
| 2549 |
+
bot.client = fake_client
|
| 2550 |
+
bot.ROUTE_MODEL_TIMEOUT_SEC = 0.05
|
| 2551 |
+
try:
|
| 2552 |
+
answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"]))
|
| 2553 |
+
assert answer == "Ответ от второй модели"
|
| 2554 |
+
assert calls[0] == "gemini-3.6-flash"
|
| 2555 |
+
finally:
|
| 2556 |
+
bot.client = original_client
|
| 2557 |
+
bot.ROUTE_MODEL_TIMEOUT_SEC = original_timeout
|
| 2558 |
+
bot.chat_state.pop(chat_id, None)
|
| 2559 |
+
|
| 2560 |
+
|
| 2561 |
+
def test_ask_gemini_falls_back_to_next_model_on_generic_error():
|
| 2562 |
+
chat_id = 999013
|
| 2563 |
+
calls = []
|
| 2564 |
+
|
| 2565 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 2566 |
+
calls.append(model)
|
| 2567 |
+
if model == "gemini-3.6-flash":
|
| 2568 |
+
raise RuntimeError("internal error 500")
|
| 2569 |
+
return _FakeGeminiResponse(text="Ответ от второй модели")
|
| 2570 |
+
|
| 2571 |
+
fake_client = MagicMock()
|
| 2572 |
+
fake_client.models.generate_content.side_effect = fake_generate_content
|
| 2573 |
+
original_client = bot.client
|
| 2574 |
+
bot.client = fake_client
|
| 2575 |
+
try:
|
| 2576 |
+
answer = asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"]))
|
| 2577 |
+
assert answer == "Ответ от второй модели"
|
| 2578 |
+
assert calls == ["gemini-3.6-flash", "gemini-2.5-flash"]
|
| 2579 |
+
finally:
|
| 2580 |
+
bot.client = original_client
|
| 2581 |
+
bot.chat_state.pop(chat_id, None)
|
| 2582 |
+
|
| 2583 |
+
|
| 2584 |
+
def test_ask_gemini_raises_when_route_budget_exceeded():
|
| 2585 |
+
chat_id = 999014
|
| 2586 |
+
|
| 2587 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 2588 |
+
raise RuntimeError("internal error 500")
|
| 2589 |
+
|
| 2590 |
+
fake_client = MagicMock()
|
| 2591 |
+
fake_client.models.generate_content.side_effect = fake_generate_content
|
| 2592 |
+
original_client = bot.client
|
| 2593 |
+
bot.client = fake_client
|
| 2594 |
+
try:
|
| 2595 |
+
past_deadline = time.monotonic() - 1.0
|
| 2596 |
+
with pytest.raises(bot.RouteBudgetExceededError):
|
| 2597 |
+
asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"], deadline=past_deadline))
|
| 2598 |
+
finally:
|
| 2599 |
+
bot.client = original_client
|
| 2600 |
+
bot.chat_state.pop(chat_id, None)
|
| 2601 |
+
|
| 2602 |
+
# ─────────────────── _or_chat_completion_with_fallback: единая классификация ошибок ───────────────────
|
| 2603 |
+
# Закрепляет, что после унификации с _classify_model_error (см. аудит техдолга)
|
| 2604 |
+
# каскад к следующей модели по-прежнему работает одинаково для любого класса
|
| 2605 |
+
# ошибки — временной (429/5xx) и внешне "постоянной" (403) — т.к. attempts_per_model
|
| 2606 |
+
# всегда 1 в реальном использовании (см. комментарий в самой функции).
|
| 2607 |
+
|
| 2608 |
+
def test_or_chat_completion_with_fallback_switches_model_on_rate_limit():
|
| 2609 |
+
async def fake_or_request(path, method="GET", *, json_body=None):
|
| 2610 |
+
model = json_body["model"]
|
| 2611 |
+
if model == "model-a":
|
| 2612 |
+
raise bot.OpenRouterAPIError("rate limit exceeded", status_code=429)
|
| 2613 |
+
return {"choices": [{"message": {"content": "ответ от model-b"}}]}
|
| 2614 |
+
|
| 2615 |
+
original = bot._or_request
|
| 2616 |
+
bot._or_request = fake_or_request
|
| 2617 |
+
try:
|
| 2618 |
+
messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
|
| 2619 |
+
answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a"))
|
| 2620 |
+
assert answer == "ответ от model-b"
|
| 2621 |
+
assert used == "model-b"
|
| 2622 |
+
finally:
|
| 2623 |
+
bot._or_request = original
|
| 2624 |
+
|
| 2625 |
+
|
| 2626 |
+
def test_or_chat_completion_with_fallback_switches_model_on_permanent_looking_error():
|
| 2627 |
+
# Даже "постоянная" на вид ошибка (403 forbidden) не должна обрывать переход
|
| 2628 |
+
# к следующей модели — при attempts_per_model=1 переход к следующей модели
|
| 2629 |
+
# происходит независимо от классификации (см. комментарий в самой функции).
|
| 2630 |
+
async def fake_or_request(path, method="GET", *, json_body=None):
|
| 2631 |
+
model = json_body["model"]
|
| 2632 |
+
if model == "model-a":
|
| 2633 |
+
raise bot.OpenRouterAPIError("forbidden", status_code=403)
|
| 2634 |
+
return {"choices": [{"message": {"content": "ответ от model-b"}}]}
|
| 2635 |
+
|
| 2636 |
+
original = bot._or_request
|
| 2637 |
+
bot._or_request = fake_or_request
|
| 2638 |
+
try:
|
| 2639 |
+
messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
|
| 2640 |
+
answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a"))
|
| 2641 |
+
assert answer == "ответ от model-b"
|
| 2642 |
+
assert used == "model-b"
|
| 2643 |
+
finally:
|
| 2644 |
+
bot._or_request = original
|