SilverElixir commited on
Commit
398ccda
·
1 Parent(s): 0e3aacf

Quarantine models after 3 bad responses until quota day ends

Browse files
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