SilverElixir commited on
Commit
09c2269
·
verified ·
1 Parent(s): 3f3369b

Upload 5 files

Browse files
Files changed (5) hide show
  1. bot.py +0 -0
  2. lumen_formatting.py +269 -0
  3. lumen_router_config.py +502 -0
  4. lumen_security.py +202 -0
  5. 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
+ модели превратились бы в видимый мусор "&lt;b&gt;".
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
- assert "модель через /model" in bot._gemini_error_msg(exc, "gemini-3.5-flash")
 
 
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
- assert "/provider" in bot._or_error_msg(exc, "text")
 
 
587
 
588
 
589
  def test_or_error_msg_unavailable():
590
  exc = _FakeStatusExc("model not found", status_code=404)
591
- assert "/provider" in bot._or_error_msg(exc, "text")
 
 
 
 
 
 
 
 
 
 
 
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