Spaces:
Running
Running
SilverElixir commited on
Commit ·
398ccda
1
Parent(s): 0e3aacf
Quarantine models after 3 bad responses until quota day ends
Browse files- bot.py +6 -0
- docs/ENVIRONMENT.md +1 -0
- lumen_commands.py +8 -0
- lumen_router_config.py +70 -2
- lumen_routes.py +7 -1
- lumen_streaming.py +2 -0
- tests/conftest.py +7 -0
- tests/test_bot_commands.py +19 -0
- tests/test_bot_routes.py +37 -0
- tests/test_lumen_router_config.py +49 -0
bot.py
CHANGED
|
@@ -605,6 +605,9 @@ from lumen_router_config import (
|
|
| 605 |
_looks_like_heavy_query,
|
| 606 |
_looks_like_freshness_query,
|
| 607 |
_build_route,
|
|
|
|
|
|
|
|
|
|
| 608 |
)
|
| 609 |
# Слой ошибок живёт в lumen_errors.py (P2): здесь только реэкспорт имён.
|
| 610 |
from lumen_errors import (
|
|
@@ -949,6 +952,9 @@ __all__ = [
|
|
| 949 |
"_looks_like_heavy_query",
|
| 950 |
"_looks_like_freshness_query",
|
| 951 |
"_build_route",
|
|
|
|
|
|
|
|
|
|
| 952 |
"_maybe_alert_gemini_exhausted",
|
| 953 |
"mark_state_dirty",
|
| 954 |
"get_state",
|
|
|
|
| 605 |
_looks_like_heavy_query,
|
| 606 |
_looks_like_freshness_query,
|
| 607 |
_build_route,
|
| 608 |
+
_record_model_outcome,
|
| 609 |
+
_is_quarantined,
|
| 610 |
+
_quarantine_status,
|
| 611 |
)
|
| 612 |
# Слой ошибок живёт в lumen_errors.py (P2): здесь только реэкспорт имён.
|
| 613 |
from lumen_errors import (
|
|
|
|
| 952 |
"_looks_like_heavy_query",
|
| 953 |
"_looks_like_freshness_query",
|
| 954 |
"_build_route",
|
| 955 |
+
"_record_model_outcome",
|
| 956 |
+
"_is_quarantined",
|
| 957 |
+
"_quarantine_status",
|
| 958 |
"_maybe_alert_gemini_exhausted",
|
| 959 |
"mark_state_dirty",
|
| 960 |
"get_state",
|
docs/ENVIRONMENT.md
CHANGED
|
@@ -75,6 +75,7 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
|
|
| 75 |
| `DAILY_USER_GEMINI_LIMIT` | `5` | Of the above, max answers that actually went through Gemini (links, YouTube, video/audio, fresh data with search). Past it, links/YouTube/video are refused, the rest is served via Groq/OpenRouter (fresh data marked as answered without search). |
|
| 76 |
| `DAILY_USER_TTS_LIMIT` | `5` | Max `/tts` voicings per user per day (counts toward `DAILY_USER_MESSAGE_LIMIT` too). |
|
| 77 |
| `ROUTE_MODEL_TIMEOUT_SEC` | `22s` | Timeout for a single attempt at a single model. No retries: any failure moves straight to the next model. |
|
|
|
|
| 78 |
| `ROUTE_TOTAL_BUDGET_SEC` | `40s` | Total time budget for the whole routing chain of one message, across all providers (Gemini, OpenRouter, Groq). |
|
| 79 |
| `DRAW_TOTAL_BUDGET_SEC` | `120s` | Same idea, for the `/draw` fallback chain across image models. |
|
| 80 |
| `CHAT_LOCK_TIMEOUT_SEC` | `45s` | How long an incoming message waits for that chat's lock before replying "busy". Deliberately shorter than `DRAW_TOTAL_BUDGET_SEC`: a second message during a long drawing gets "busy" instead of hanging. Shared by normal messages, pick-buttons and albums. |
|
|
|
|
| 75 |
| `DAILY_USER_GEMINI_LIMIT` | `5` | Of the above, max answers that actually went through Gemini (links, YouTube, video/audio, fresh data with search). Past it, links/YouTube/video are refused, the rest is served via Groq/OpenRouter (fresh data marked as answered without search). |
|
| 76 |
| `DAILY_USER_TTS_LIMIT` | `5` | Max `/tts` voicings per user per day (counts toward `DAILY_USER_MESSAGE_LIMIT` too). |
|
| 77 |
| `ROUTE_MODEL_TIMEOUT_SEC` | `22s` | Timeout for a single attempt at a single model. No retries: any failure moves straight to the next model. |
|
| 78 |
+
| `MODEL_QUARANTINE_BAD_LIMIT` | `3` | How many consecutive bad responses (empty or garbled) put a model in temporary quarantine until the end of the quota day. A good response resets the counter; the last available model of a route is never quarantined. In-memory only. |
|
| 79 |
| `ROUTE_TOTAL_BUDGET_SEC` | `40s` | Total time budget for the whole routing chain of one message, across all providers (Gemini, OpenRouter, Groq). |
|
| 80 |
| `DRAW_TOTAL_BUDGET_SEC` | `120s` | Same idea, for the `/draw` fallback chain across image models. |
|
| 81 |
| `CHAT_LOCK_TIMEOUT_SEC` | `45s` | How long an incoming message waits for that chat's lock before replying "busy". Deliberately shorter than `DRAW_TOTAL_BUDGET_SEC`: a second message during a long drawing gets "busy" instead of hanging. Shared by normal messages, pick-buttons and albums. |
|
lumen_commands.py
CHANGED
|
@@ -602,6 +602,13 @@ async def cmd_stats(message: Message) -> None:
|
|
| 602 |
gemini_text = _quota_section(gemini_quota, None, bot.GEMINI_DAILY_LIMITS)
|
| 603 |
or_text = _quota_section(bot.GLOBAL_QUOTA.get("openrouter", {}), bot.OPENROUTER_DAILY_LIMIT)
|
| 604 |
groq_text = _quota_section(bot.GLOBAL_QUOTA.get("groq", {}), bot.GROQ_DAILY_LIMIT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
|
| 606 |
# Суточные счётчики: уникальные пользователи — только числом, без ID.
|
| 607 |
_day_stats = bot._stats_entry()
|
|
@@ -628,6 +635,7 @@ async def cmd_stats(message: Message) -> None:
|
|
| 628 |
f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
|
| 629 |
f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}\n\n"
|
| 630 |
f"<b>Groq — запросов по моделям:</b>\n{groq_text}\n\n"
|
|
|
|
| 631 |
f"Вебхук: {webhook_text}\n"
|
| 632 |
f"Память: {_process_memory_text()}\n"
|
| 633 |
f"Хранилище: {_storage_backend_text()}"
|
|
|
|
| 602 |
gemini_text = _quota_section(gemini_quota, None, bot.GEMINI_DAILY_LIMITS)
|
| 603 |
or_text = _quota_section(bot.GLOBAL_QUOTA.get("openrouter", {}), bot.OPENROUTER_DAILY_LIMIT)
|
| 604 |
groq_text = _quota_section(bot.GLOBAL_QUOTA.get("groq", {}), bot.GROQ_DAILY_LIMIT)
|
| 605 |
+
_quarantined_now = bot._quarantine_status()
|
| 606 |
+
if _quarantined_now:
|
| 607 |
+
quarantine_text = "\n".join(
|
| 608 |
+
f" • {provider}/{mid}: {bad} плохих подряд (до конца суток)" for provider, mid, bad in _quarantined_now
|
| 609 |
+
)
|
| 610 |
+
else:
|
| 611 |
+
quarantine_text = " нет"
|
| 612 |
|
| 613 |
# Суточные счётчики: уникальные пользователи — только числом, без ID.
|
| 614 |
_day_stats = bot._stats_entry()
|
|
|
|
| 635 |
f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
|
| 636 |
f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}\n\n"
|
| 637 |
f"<b>Groq — запросов по моделям:</b>\n{groq_text}\n\n"
|
| 638 |
+
f"<b>Карантин моделей:</b>\n{quarantine_text}\n\n"
|
| 639 |
f"Вебхук: {webhook_text}\n"
|
| 640 |
f"Память: {_process_memory_text()}\n"
|
| 641 |
f"Хранилище: {_storage_backend_text()}"
|
lumen_router_config.py
CHANGED
|
@@ -7,6 +7,7 @@ lumen_router_config.py — модели и выбор маршрута (Gemini/O
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import logging
|
|
|
|
| 10 |
import re
|
| 11 |
import time
|
| 12 |
from dataclasses import dataclass
|
|
@@ -266,10 +267,77 @@ def _is_quota_exhausted(provider: str, model_id: str) -> bool:
|
|
| 266 |
return False
|
| 267 |
|
| 268 |
def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
|
| 269 |
-
"""Убирает модели с меткой квоты, но никогда не возвращает пустой список: один заведомо мёртвый вариант честнее мгновенного отказа (аудит 26.09.2026)."""
|
| 270 |
-
alive = [m for m in models if not _is_quota_exhausted(provider, m)]
|
| 271 |
return alive or list(models)
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def _or_route(models: list[str]) -> list[tuple[str, str]]:
|
| 274 |
"""Список ID в пары (provider, model_id), минус реестр исключённых и модели с исчерпанной квотой."""
|
| 275 |
return [("openrouter", m) for m in _skip_exhausted("openrouter", models) if m not in _ROUTER_EXCLUDED_OR_MODELS]
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import logging
|
| 10 |
+
import os
|
| 11 |
import re
|
| 12 |
import time
|
| 13 |
from dataclasses import dataclass
|
|
|
|
| 267 |
return False
|
| 268 |
|
| 269 |
def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
|
| 270 |
+
"""Убирает модели с меткой квоты и временным карантином, но никогда не возвращает пустой список: один заведомо мёртвый вариант честнее мгновенного отказа (аудит 26.09.2026)."""
|
| 271 |
+
alive = [m for m in models if not _is_quota_exhausted(provider, m) and not _is_quarantined(provider, m)]
|
| 272 |
return alive or list(models)
|
| 273 |
|
| 274 |
+
# ── Временный карантин моделей ──
|
| 275 |
+
# Только в памяти: N плохих ответов подряд (пусто или mush-каша) — и роутер
|
| 276 |
+
# пропускает модель до конца суток квоты. Реестр _OR_MODEL_HEALTH не трогаем:
|
| 277 |
+
# туда только датированные доказательства.
|
| 278 |
+
_QUARANTINE: dict[tuple[str, str], dict[str, Any]] = {}
|
| 279 |
+
|
| 280 |
+
def _quarantine_threshold() -> int:
|
| 281 |
+
"""Сколько плохих подряд до карантина (MODEL_QUARANTINE_BAD_LIMIT, дефолт 3)."""
|
| 282 |
+
try:
|
| 283 |
+
raw = (os.getenv("MODEL_QUARANTINE_BAD_LIMIT", "") or "").strip()
|
| 284 |
+
value = int(raw) if raw else 3
|
| 285 |
+
except (TypeError, ValueError):
|
| 286 |
+
return 3
|
| 287 |
+
return max(1, value)
|
| 288 |
+
|
| 289 |
+
def _quarantine_entry(provider: str, model_id: str) -> dict[str, Any]:
|
| 290 |
+
"""Сегодняшняя запись счётчика: смена суток квоты обнуляет молча."""
|
| 291 |
+
import bot
|
| 292 |
+
today = bot._current_quota_day()
|
| 293 |
+
key = (provider, model_id)
|
| 294 |
+
entry = _QUARANTINE.get(key)
|
| 295 |
+
if entry is None or entry.get("day") != today:
|
| 296 |
+
entry = {"bad": 0, "day": today}
|
| 297 |
+
_QUARANTINE[key] = entry
|
| 298 |
+
return entry
|
| 299 |
+
|
| 300 |
+
def _record_model_outcome(provider: str, model_id: str, *, bad: bool) -> None:
|
| 301 |
+
"""Плохой ответ +1 к счётчику (на пороге — карантин с записью в лог),
|
| 302 |
+
успешный — сбрасывает счётчик."""
|
| 303 |
+
if not bad:
|
| 304 |
+
if _QUARANTINE.pop((provider, model_id), None) is not None:
|
| 305 |
+
log.debug("[quarantine] %s/%s answered well, counter reset.", provider, model_id)
|
| 306 |
+
return
|
| 307 |
+
entry = _quarantine_entry(provider, model_id)
|
| 308 |
+
entry["bad"] = int(entry.get("bad") or 0) + 1
|
| 309 |
+
if entry["bad"] == _quarantine_threshold():
|
| 310 |
+
log.warning(
|
| 311 |
+
"[quarantine] %s/%s quarantined until end of quota day %s after %d consecutive bad responses (empty or garbled).",
|
| 312 |
+
provider, model_id, entry["day"], entry["bad"],
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
def _is_quarantined(provider: str, model_id: str) -> bool:
|
| 316 |
+
try:
|
| 317 |
+
import bot
|
| 318 |
+
entry = _QUARANTINE.get((provider, model_id))
|
| 319 |
+
return bool(entry and entry.get("day") == bot._current_quota_day()
|
| 320 |
+
and int(entry.get("bad") or 0) >= _quarantine_threshold())
|
| 321 |
+
except Exception as exc:
|
| 322 |
+
# Тот же fail-open, что у _is_quota_exhausted: битое состояние не должно
|
| 323 |
+
# молча выкидывать живые модели из маршрута.
|
| 324 |
+
log.warning("[router] Quarantine state unreadable, treating %s/%s as available: %s", provider, model_id, exc)
|
| 325 |
+
return False
|
| 326 |
+
|
| 327 |
+
def _quarantine_status() -> list[tuple[str, str, int]]:
|
| 328 |
+
"""Кто сейчас в карантине: для /stats. Пустые и вчерашние записи не показываем."""
|
| 329 |
+
try:
|
| 330 |
+
import bot
|
| 331 |
+
today = bot._current_quota_day()
|
| 332 |
+
threshold = _quarantine_threshold()
|
| 333 |
+
return sorted(
|
| 334 |
+
(provider, model_id, int(entry.get("bad") or 0))
|
| 335 |
+
for (provider, model_id), entry in _QUARANTINE.items()
|
| 336 |
+
if entry.get("day") == today and int(entry.get("bad") or 0) >= threshold
|
| 337 |
+
)
|
| 338 |
+
except Exception:
|
| 339 |
+
return []
|
| 340 |
+
|
| 341 |
def _or_route(models: list[str]) -> list[tuple[str, str]]:
|
| 342 |
"""Список ID в пары (provider, model_id), минус реестр исключённых и модели с исчерпанной квотой."""
|
| 343 |
return [("openrouter", m) for m in _skip_exhausted("openrouter", models) if m not in _ROUTER_EXCLUDED_OR_MODELS]
|
lumen_routes.py
CHANGED
|
@@ -35,7 +35,7 @@ from lumen_router_config import (
|
|
| 35 |
GEMINI_DEFAULT_CHAIN,
|
| 36 |
_looks_like_heavy_query,
|
| 37 |
)
|
| 38 |
-
from lumen_security import _scrub_identity_leak
|
| 39 |
|
| 40 |
# Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
|
| 41 |
_TRANSCRIPT_MAX_CHARS = 4000
|
|
@@ -254,9 +254,13 @@ async def _chat_completion_chain(
|
|
| 254 |
answer = answer.strip()
|
| 255 |
if not answer:
|
| 256 |
# Пустой ответ — повод попробовать следующую модель (прод 17.09.2026: юзер дважды увидел "Empty response").
|
|
|
|
| 257 |
raise RuntimeError(f"Model {model_trial} returned an empty response")
|
| 258 |
|
| 259 |
answer = _scrub_identity_leak(answer, source=f"{provider}_chat_completion:{model_trial}")
|
|
|
|
|
|
|
|
|
|
| 260 |
log.info('[%s] Successful response from model %s (primary=%s, models tried: %d)', label, model_trial, primary_model_id, len(tried))
|
| 261 |
_pt, _ct, _tt = _usage_from_openai_response(resp)
|
| 262 |
_log_llm_usage(chat_id=chat_id, provider=provider, model_id=model_trial,
|
|
@@ -777,6 +781,7 @@ async def ask_gemini(
|
|
| 777 |
ans = ans.strip()
|
| 778 |
if not ans:
|
| 779 |
# Пустой ответ — пробуем следующую модель (прод 17.09.2026: юзер увидел буквальное "Empty response").
|
|
|
|
| 780 |
raise RuntimeError(f"Model {curr_model_id} returned an empty response")
|
| 781 |
break
|
| 782 |
except (asyncio.TimeoutError, asyncio.CancelledError) as exc:
|
|
@@ -833,6 +838,7 @@ async def ask_gemini(
|
|
| 833 |
_record_model_latency(_model_speed_key("gemini", curr_model_id), total_sec=time.monotonic() - attempt_start)
|
| 834 |
|
| 835 |
ans = _scrub_identity_leak(ans, source=f"ask_gemini:{curr_model_id}")
|
|
|
|
| 836 |
|
| 837 |
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 838 |
hist.append({"role": "assistant", "content": ans})
|
|
|
|
| 35 |
GEMINI_DEFAULT_CHAIN,
|
| 36 |
_looks_like_heavy_query,
|
| 37 |
)
|
| 38 |
+
from lumen_security import _scrub_identity_leak, _detect_garbled_mix
|
| 39 |
|
| 40 |
# Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
|
| 41 |
_TRANSCRIPT_MAX_CHARS = 4000
|
|
|
|
| 254 |
answer = answer.strip()
|
| 255 |
if not answer:
|
| 256 |
# Пустой ответ — повод попробовать следующую модель (прод 17.09.2026: юзер дважды увидел "Empty response").
|
| 257 |
+
bot._record_model_outcome(provider, model_trial, bad=True)
|
| 258 |
raise RuntimeError(f"Model {model_trial} returned an empty response")
|
| 259 |
|
| 260 |
answer = _scrub_identity_leak(answer, source=f"{provider}_chat_completion:{model_trial}")
|
| 261 |
+
# Каша-каша тоже считается плохим (доставляется как есть, как раньше),
|
| 262 |
+
# успех сбрасывает счётчик карантина.
|
| 263 |
+
bot._record_model_outcome(provider, model_trial, bad=_detect_garbled_mix(answer))
|
| 264 |
log.info('[%s] Successful response from model %s (primary=%s, models tried: %d)', label, model_trial, primary_model_id, len(tried))
|
| 265 |
_pt, _ct, _tt = _usage_from_openai_response(resp)
|
| 266 |
_log_llm_usage(chat_id=chat_id, provider=provider, model_id=model_trial,
|
|
|
|
| 781 |
ans = ans.strip()
|
| 782 |
if not ans:
|
| 783 |
# Пустой ответ — пробуем следующую модель (прод 17.09.2026: юзер увидел буквальное "Empty response").
|
| 784 |
+
bot._record_model_outcome("gemini", curr_model_id, bad=True)
|
| 785 |
raise RuntimeError(f"Model {curr_model_id} returned an empty response")
|
| 786 |
break
|
| 787 |
except (asyncio.TimeoutError, asyncio.CancelledError) as exc:
|
|
|
|
| 838 |
_record_model_latency(_model_speed_key("gemini", curr_model_id), total_sec=time.monotonic() - attempt_start)
|
| 839 |
|
| 840 |
ans = _scrub_identity_leak(ans, source=f"ask_gemini:{curr_model_id}")
|
| 841 |
+
bot._record_model_outcome("gemini", curr_model_id, bad=_detect_garbled_mix(ans))
|
| 842 |
|
| 843 |
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 844 |
hist.append({"role": "assistant", "content": ans})
|
lumen_streaming.py
CHANGED
|
@@ -27,6 +27,7 @@ from lumen_security import (
|
|
| 27 |
_IDENTITY_LEAK_FALLBACK,
|
| 28 |
_INJECTED_PAYLOAD_ECHO_FALLBACK,
|
| 29 |
_detect_injected_payload_echo,
|
|
|
|
| 30 |
_detect_identity_leak,
|
| 31 |
_scrub_identity_leak,
|
| 32 |
)
|
|
@@ -429,6 +430,7 @@ async def _run_streaming_reply(
|
|
| 429 |
|
| 430 |
# Пустой full_text сюда не доходит — ветки "Empty response" нет.
|
| 431 |
final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
|
|
|
| 432 |
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 433 |
hist.append({"role": "assistant", "content": final_answer})
|
| 434 |
# Тот же _trim_history, что в нестриминговых путях (см. комментарий выше).
|
|
|
|
| 27 |
_IDENTITY_LEAK_FALLBACK,
|
| 28 |
_INJECTED_PAYLOAD_ECHO_FALLBACK,
|
| 29 |
_detect_injected_payload_echo,
|
| 30 |
+
_detect_garbled_mix,
|
| 31 |
_detect_identity_leak,
|
| 32 |
_scrub_identity_leak,
|
| 33 |
)
|
|
|
|
| 430 |
|
| 431 |
# Пустой full_text сюда не доходит — ветки "Empty response" нет.
|
| 432 |
final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
| 433 |
+
bot._record_model_outcome(provider, model_id, bad=_detect_garbled_mix(full_text.strip()))
|
| 434 |
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 435 |
hist.append({"role": "assistant", "content": final_answer})
|
| 436 |
# Тот же _trim_history, что в нестриминговых путях (см. комментарий выше).
|
tests/conftest.py
CHANGED
|
@@ -24,12 +24,16 @@ from unittest.mock import AsyncMock
|
|
| 24 |
|
| 25 |
import bot
|
| 26 |
import lumen_limits
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
@pytest.fixture(autouse=True)
|
| 30 |
def _bot_global_state_guard():
|
| 31 |
"""Снимок наиболее часто вручную сохраняемых модульных глобалов bot.py перед
|
| 32 |
каждым тестом и восстановление после (аудит техдолга, август 2026).
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
Десятки тестов в test_bot_*.py вручную сохраняли/восстанавливали
|
| 35 |
bot.chat_state/bot.GLOBAL_QUOTA/bot.client/bot.bot в try/finally — рабочий, но
|
|
@@ -48,6 +52,7 @@ def _bot_global_state_guard():
|
|
| 48 |
import bot as _bot_module
|
| 49 |
chat_state_snapshot = copy.deepcopy(_bot_module.chat_state)
|
| 50 |
quota_snapshot = copy.deepcopy(_bot_module.GLOBAL_QUOTA)
|
|
|
|
| 51 |
client_snapshot = _bot_module.client
|
| 52 |
bot_snapshot = _bot_module.bot
|
| 53 |
yield
|
|
@@ -55,6 +60,8 @@ def _bot_global_state_guard():
|
|
| 55 |
_bot_module.chat_state.update(chat_state_snapshot)
|
| 56 |
_bot_module.GLOBAL_QUOTA.clear()
|
| 57 |
_bot_module.GLOBAL_QUOTA.update(quota_snapshot)
|
|
|
|
|
|
|
| 58 |
_bot_module.client = client_snapshot
|
| 59 |
_bot_module.bot = bot_snapshot
|
| 60 |
|
|
|
|
| 24 |
|
| 25 |
import bot
|
| 26 |
import lumen_limits
|
| 27 |
+
import lumen_router_config
|
| 28 |
|
| 29 |
|
| 30 |
@pytest.fixture(autouse=True)
|
| 31 |
def _bot_global_state_guard():
|
| 32 |
"""Снимок наиболее часто вручную сохраняемых модульных глобалов bot.py перед
|
| 33 |
каждым тестом и восстановление после (аудит техдолга, август 2026).
|
| 34 |
+
Карантин моделей (lumen_router_config._QUARANTINE) тоже снимается: пустые
|
| 35 |
+
ответы в тестах честно пишут в счётчик, без сброса один и тот же фейковый
|
| 36 |
+
ID за три теста ушёл бы в карантин и начал фильтровать маршруты.
|
| 37 |
|
| 38 |
Десятки тестов в test_bot_*.py вручную сохраняли/восстанавливали
|
| 39 |
bot.chat_state/bot.GLOBAL_QUOTA/bot.client/bot.bot в try/finally — рабочий, но
|
|
|
|
| 52 |
import bot as _bot_module
|
| 53 |
chat_state_snapshot = copy.deepcopy(_bot_module.chat_state)
|
| 54 |
quota_snapshot = copy.deepcopy(_bot_module.GLOBAL_QUOTA)
|
| 55 |
+
quarantine_snapshot = copy.deepcopy(lumen_router_config._QUARANTINE)
|
| 56 |
client_snapshot = _bot_module.client
|
| 57 |
bot_snapshot = _bot_module.bot
|
| 58 |
yield
|
|
|
|
| 60 |
_bot_module.chat_state.update(chat_state_snapshot)
|
| 61 |
_bot_module.GLOBAL_QUOTA.clear()
|
| 62 |
_bot_module.GLOBAL_QUOTA.update(quota_snapshot)
|
| 63 |
+
lumen_router_config._QUARANTINE.clear()
|
| 64 |
+
lumen_router_config._QUARANTINE.update(quarantine_snapshot)
|
| 65 |
_bot_module.client = client_snapshot
|
| 66 |
_bot_module.bot = bot_snapshot
|
| 67 |
|
tests/test_bot_commands.py
CHANGED
|
@@ -1426,3 +1426,22 @@ def test_selftest_llm_head_openrouter_error_returns_false_without_quota(monkeypa
|
|
| 1426 |
assert ok is False and "boom-429" in detail and elapsed >= 0
|
| 1427 |
after = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
|
| 1428 |
assert after == before
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1426 |
assert ok is False and "boom-429" in detail and elapsed >= 0
|
| 1427 |
after = ((bot.GLOBAL_QUOTA.get("openrouter") or {}).get(head) or {}).get("used") or 0
|
| 1428 |
assert after == before
|
| 1429 |
+
|
| 1430 |
+
|
| 1431 |
+
def test_cmd_stats_shows_quarantine(monkeypatch):
|
| 1432 |
+
# Карантин виден владельцу в /stats: иначе объезд модели выглядел бы магией.
|
| 1433 |
+
monkeypatch.setattr(bot, "OWNER_ID", 888001)
|
| 1434 |
+
for _ in range(3):
|
| 1435 |
+
bot._record_model_outcome("openrouter", "qs-model:free", bad=True)
|
| 1436 |
+
incoming = _FakeIncomingMessage(999707)
|
| 1437 |
+
incoming.from_user = SimpleNamespace(id=888001)
|
| 1438 |
+
sent = {}
|
| 1439 |
+
|
| 1440 |
+
async def fake_tg_call(method, *args, **kwargs):
|
| 1441 |
+
sent["text"] = args[0] if args else kwargs.get("text", "")
|
| 1442 |
+
return SimpleNamespace()
|
| 1443 |
+
|
| 1444 |
+
monkeypatch.setattr(bot, "_tg_call", fake_tg_call)
|
| 1445 |
+
asyncio.run(bot.cmd_stats(incoming))
|
| 1446 |
+
assert "Карантин моделей" in sent["text"]
|
| 1447 |
+
assert "qs-model:free" in sent["text"]
|
tests/test_bot_routes.py
CHANGED
|
@@ -457,6 +457,43 @@ def test_or_empty_response_falls_through_to_next_model(monkeypatch):
|
|
| 457 |
assert calls == ["m1:free", "m2:free"]
|
| 458 |
|
| 459 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
def test_attempt_timeout_caps_by_remaining_route_budget():
|
| 461 |
# Регрессия (аудит 26.09.2026): каждая попытка ждала полные ROUTE_MODEL_TIMEOUT_SEC,
|
| 462 |
# поэтому маршрут держал лок чата до 40+22с вместо заявленных 40с.
|
|
|
|
| 457 |
assert calls == ["m1:free", "m2:free"]
|
| 458 |
|
| 459 |
|
| 460 |
+
def test_chain_empty_responses_feed_quarantine_counter(monkeypatch):
|
| 461 |
+
# Три пустых подряд в одну голову — карантин (реальная регрессия: мёртвая голова
|
| 462 |
+
# жевала бы каждую попытку до конца суток вместо объезда после третьей).
|
| 463 |
+
async def fake_empty(path, method="GET", *, json_body=None, deadline=None):
|
| 464 |
+
return {"choices": []}
|
| 465 |
+
|
| 466 |
+
monkeypatch.setattr(bot, "_or_request", fake_empty)
|
| 467 |
+
messages = [
|
| 468 |
+
{"role": "system", "content": "sys"},
|
| 469 |
+
{"role": "user", "content": "hi"},
|
| 470 |
+
]
|
| 471 |
+
for _ in range(3):
|
| 472 |
+
with pytest.raises(RuntimeError, match="empty response"):
|
| 473 |
+
asyncio.run(bot._or_chat_completion_with_fallback(messages, ["qe:free"], "qe:free"))
|
| 474 |
+
assert bot._is_quarantined("openrouter", "qe:free") is True
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def test_chain_garbled_response_delivered_but_feeds_quarantine(monkeypatch):
|
| 478 |
+
# Каша доставляется как раньше (без подмены), но в счётчик идёт: три таких —
|
| 479 |
+
# и голова в карантине.
|
| 480 |
+
mush = "результат: " + "dataданные fileфайл testтест " + "продолжение " + "я" * 80
|
| 481 |
+
assert len(mush) >= 100
|
| 482 |
+
|
| 483 |
+
async def fake_mush(path, method="GET", *, json_body=None, deadline=None):
|
| 484 |
+
return {"choices": [{"message": {"content": mush}}]}
|
| 485 |
+
|
| 486 |
+
monkeypatch.setattr(bot, "_or_request", fake_mush)
|
| 487 |
+
messages = [
|
| 488 |
+
{"role": "system", "content": "sys"},
|
| 489 |
+
{"role": "user", "content": "hi"},
|
| 490 |
+
]
|
| 491 |
+
for _ in range(3):
|
| 492 |
+
answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["qm:free"], "qm:free"))
|
| 493 |
+
assert (answer, used) == (mush, "qm:free")
|
| 494 |
+
assert bot._is_quarantined("openrouter", "qm:free") is True
|
| 495 |
+
|
| 496 |
+
|
| 497 |
def test_attempt_timeout_caps_by_remaining_route_budget():
|
| 498 |
# Регрессия (аудит 26.09.2026): каждая попытка ждала полные ROUTE_MODEL_TIMEOUT_SEC,
|
| 499 |
# поэтому маршрут держал лок чата до 40+22с вместо заявленных 40с.
|
tests/test_lumen_router_config.py
CHANGED
|
@@ -729,3 +729,52 @@ def test_quota_exhausted_logs_broken_state_loudly(monkeypatch, caplog):
|
|
| 729 |
with caplog.at_level("WARNING", logger="bot"):
|
| 730 |
assert lumen_router_config._is_quota_exhausted("openrouter", "m") is False
|
| 731 |
assert "Quota state unreadable" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
with caplog.at_level("WARNING", logger="bot"):
|
| 730 |
assert lumen_router_config._is_quota_exhausted("openrouter", "m") is False
|
| 731 |
assert "Quota state unreadable" in caplog.text
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def test_quarantine_triggers_after_three_consecutive_bad_and_skips_model(caplog):
|
| 735 |
+
# Три плохих подряд — карантин до конца суток: роутер объезжает модель.
|
| 736 |
+
import bot
|
| 737 |
+
with caplog.at_level("WARNING", logger="bot"):
|
| 738 |
+
for _ in range(3):
|
| 739 |
+
bot._record_model_outcome("openrouter", "q-model:free", bad=True)
|
| 740 |
+
assert bot._is_quarantined("openrouter", "q-model:free") is True
|
| 741 |
+
assert "[quarantine]" in caplog.text
|
| 742 |
+
assert bot._quarantine_status() == [("openrouter", "q-model:free", 3)]
|
| 743 |
+
from lumen_router_config import _OR_LIGHT_ORDER
|
| 744 |
+
route = [m for m in _OR_LIGHT_ORDER if m != "q-model:free"] + ["q-model:free"]
|
| 745 |
+
assert "q-model:free" not in lumen_router_config._skip_exhausted("openrouter", route)
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def test_quarantine_never_empties_route_and_success_resets(caplog):
|
| 749 |
+
# Последняя доступная модель не карантинится де-факто: пустой маршрут не возвращаем.
|
| 750 |
+
import bot
|
| 751 |
+
for _ in range(5):
|
| 752 |
+
bot._record_model_outcome("groq", "only-model", bad=True)
|
| 753 |
+
assert bot._is_quarantined("groq", "only-model") is True
|
| 754 |
+
assert lumen_router_config._skip_exhausted("groq", ["only-model"]) == ["only-model"]
|
| 755 |
+
bot._record_model_outcome("groq", "only-model", bad=False)
|
| 756 |
+
assert bot._is_quarantined("groq", "only-model") is False
|
| 757 |
+
assert bot._quarantine_status() == []
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def test_quarantine_resets_on_quota_day_change(monkeypatch):
|
| 761 |
+
# Карантин только до конца суток квоты: новый день — чистый счётчик.
|
| 762 |
+
import bot
|
| 763 |
+
monkeypatch.setattr(bot, "_current_quota_day", lambda: "2099-01-01")
|
| 764 |
+
for _ in range(3):
|
| 765 |
+
bot._record_model_outcome("openrouter", "day-model:free", bad=True)
|
| 766 |
+
assert bot._is_quarantined("openrouter", "day-model:free") is True
|
| 767 |
+
monkeypatch.setattr(bot, "_current_quota_day", lambda: "2099-01-02")
|
| 768 |
+
assert bot._is_quarantined("openrouter", "day-model:free") is False
|
| 769 |
+
assert bot._quarantine_status() == []
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def test_quarantine_threshold_reads_env(monkeypatch):
|
| 773 |
+
# N из env: при 2 карантин наступает раньше (дефолт 3 проверен выше).
|
| 774 |
+
import bot
|
| 775 |
+
monkeypatch.setenv("MODEL_QUARANTINE_BAD_LIMIT", "2")
|
| 776 |
+
assert lumen_router_config._quarantine_threshold() == 2
|
| 777 |
+
bot._record_model_outcome("openrouter", "env-model:free", bad=True)
|
| 778 |
+
assert bot._is_quarantined("openrouter", "env-model:free") is False
|
| 779 |
+
bot._record_model_outcome("openrouter", "env-model:free", bad=True)
|
| 780 |
+
assert bot._is_quarantined("openrouter", "env-model:free") is True
|