SilverElixir commited on
Commit
83fef4a
·
1 Parent(s): 1c833d2

Reliability wave 3: router skips quota-exhausted models, prune never drops a chat holding the lock

Browse files
lumen_chat_state.py CHANGED
@@ -551,16 +551,26 @@ def _t(chat_id: int | None, key: str, **kwargs: Any) -> str:
551
  def _prune_old_chats() -> None:
552
  import bot
553
  sorted_ids = sorted(chat_state.keys(), key=lambda cid: chat_state[cid].get("last_activity", 0))
554
- to_remove = len(chat_state) - PRUNED_CHAT_TARGET
555
- removed_ids = sorted_ids[:to_remove]
 
 
 
 
 
 
 
 
 
556
  for cid in removed_ids:
557
  chat_state.pop(cid, None)
558
  bot._chat_locks.pop(cid, None)
559
  # Вытесненные чаты должны реально исчезнуть из хранилища (иначе их собственные
560
- # ключи/файлы бесхозно копятся навсегда) — ставим в очередь на удаление,
561
  # обрабатывается в _flush_dirty_state вместе с обычным сбросом.
562
  _pending_chat_deletions.update(removed_ids)
563
- bot.mark_state_dirty()
 
564
 
565
  def get_chat_lock(chat_id: int) -> asyncio.Lock:
566
  import bot
 
551
  def _prune_old_chats() -> None:
552
  import bot
553
  sorted_ids = sorted(chat_state.keys(), key=lambda cid: chat_state[cid].get("last_activity", 0))
554
+ # Лок ЗАНЯТ = в чате идёт ответ. Такой чат вытеснять нельзя: идущий маршрут
555
+ # продолжал бы писать в отвязанный state, а следующее сообщение создало бы
556
+ # новый лок — два параллельных ответа в одном чате и порча истории
557
+ # (аудит 26.09.2026). Такой чат просто не попадает в выборку.
558
+ def _is_busy(cid: int) -> bool:
559
+ lock = bot._chat_locks.get(cid)
560
+ return lock is not None and lock.locked()
561
+
562
+ removable = [cid for cid in sorted_ids if not _is_busy(cid)]
563
+ to_remove = max(0, len(removable) - PRUNED_CHAT_TARGET)
564
+ removed_ids = removable[:to_remove]
565
  for cid in removed_ids:
566
  chat_state.pop(cid, None)
567
  bot._chat_locks.pop(cid, None)
568
  # Вытесненные чаты должны реально исчезнуть из хранилища (иначе их собственные
569
+ # ключи/файлы бесхозно копятся вечно) — ставим в очередь на удаление,
570
  # обрабатывается в _flush_dirty_state вместе с обычным сбросом.
571
  _pending_chat_deletions.update(removed_ids)
572
+ if removed_ids:
573
+ bot.mark_state_dirty()
574
 
575
  def get_chat_lock(chat_id: int) -> asyncio.Lock:
576
  import bot
lumen_router_config.py CHANGED
@@ -291,13 +291,34 @@ def _check_scheduled_removals_due() -> None:
291
  model_id, removal_date.isoformat(), today.isoformat(),
292
  )
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  def _or_route(models: list[str]) -> list[tuple[str, str]]:
295
  """Превращает список ID моделей OpenRouter в список (provider, model_id) для
296
- маршрута, попутно исключая модели из _ROUTER_EXCLUDED_OR_MODELS."""
297
- return [("openrouter", m) for m in models if m not in _ROUTER_EXCLUDED_OR_MODELS]
 
298
 
299
  def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
300
- return [("gemini", m) for m in models]
301
 
302
  # ── Groq (прямой провайдер, не через OpenRouter) ──
303
  # Калибровка русского живьём 21.09.2026 (4 пробы на модель через API с VPN): Qwen отвечает чисто и по делу, gpt-oss-120b тоже верен, но представляется ChatGPT от OpenAI (ловит фильтр утечек) и тратит 30-70 reasoning-токенов на ответ — они едят минутный бюджет. Поэтому Qwen голова, gpt-oss второй. Лимиты free-плана по офиц. доке: 30 RPM / 1000 RPD / 8K TPM / 200K TPD на модель.
@@ -308,7 +329,7 @@ _GROQ_LIGHT_ORDER: list[str] = [
308
  ]
309
 
310
  def _groq_route(models: list[str]) -> list[tuple[str, str]]:
311
- return [("groq", m) for m in models]
312
 
313
 
314
  # ── "Лёгкие" запросы — САМЫЙ ЧАСТЫЙ маршрут, целиком OpenRouter (квоту Gemini не трогаем).
 
291
  model_id, removal_date.isoformat(), today.isoformat(),
292
  )
293
 
294
+ def _is_quota_exhausted(provider: str, model_id: str) -> bool:
295
+ """Помечена ли модель как исчерпавшая квоту (метка _mark_quota_exhausted после 429).
296
+ Раньше роутер её не читал: каждое сообщение снова упиралось в 429 заведомо мёртвой
297
+ модели и тратило ROUTE_MODEL_TIMEOUT_SEC попытки (аудит 26.09.2026). Сброс —
298
+ смена суток (_reset_quota_if_new_day) либо успешный ответ, который чистит метку."""
299
+ try:
300
+ import bot
301
+ sub = bot.GLOBAL_QUOTA.get(provider) or {}
302
+ entry = sub.get(model_id) or {}
303
+ return bool(entry.get("exhausted_at"))
304
+ except Exception:
305
+ return False
306
+
307
+ def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
308
+ """Выкидывает модели с меткой «квота исчерпана», но НИКОГДА не оставляет список
309
+ пустым: пустой маршрут означал бы «упасть сразу», а один заведомо мёртвый
310
+ вариант всё ещё даёт пользователю честное сообщение о лимите (аудит 26.09.2026)."""
311
+ alive = [m for m in models if not _is_quota_exhausted(provider, m)]
312
+ return alive or list(models)
313
+
314
  def _or_route(models: list[str]) -> list[tuple[str, str]]:
315
  """Превращает список ID моделей OpenRouter в список (provider, model_id) для
316
+ маршрута, попутно исключая модели из _ROUTER_EXCLUDED_OR_MODELS и уже помеченные
317
+ как исчерпавшие квоту (иначе они каждый раз съедали бы попытку до 429)."""
318
+ return [("openrouter", m) for m in _skip_exhausted("openrouter", models) if m not in _ROUTER_EXCLUDED_OR_MODELS]
319
 
320
  def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
321
+ return [("gemini", m) for m in _skip_exhausted("gemini", models)]
322
 
323
  # ── Groq (прямой провайдер, не через OpenRouter) ──
324
  # Калибровка русского живьём 21.09.2026 (4 пробы на модель через API с VPN): Qwen отвечает чисто и по делу, gpt-oss-120b тоже верен, но представляется ChatGPT от OpenAI (ловит фильтр утечек) и тратит 30-70 reasoning-токенов на ответ — они едят минутный бюджет. Поэтому Qwen голова, gpt-oss второй. Лимиты free-плана по офиц. доке: 30 RPM / 1000 RPD / 8K TPM / 200K TPD на модель.
 
329
  ]
330
 
331
  def _groq_route(models: list[str]) -> list[tuple[str, str]]:
332
+ return [("groq", m) for m in _skip_exhausted("groq", models)]
333
 
334
 
335
  # ── "Лёгкие" запросы — САМЫЙ ЧАСТЫЙ маршрут, целиком OpenRouter (квоту Gemini не трогаем).
tests/test_bot_state.py CHANGED
@@ -631,6 +631,45 @@ def test_process_media_group_buffers_holds_chat_lock():
631
  bot.chat_state.pop(chat_id, None)
632
 
633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
  def test_process_media_group_buffers_cleanup_on_cancel():
635
  # Отмена во сне (рестарт/дренаж) — записи буфера и задачи уходят, а не висят вечно.
636
  bot._mg_buffers["mgcancel"] = ["m1"]
 
631
  bot.chat_state.pop(chat_id, None)
632
 
633
 
634
+ def test_prune_old_chats_never_drops_a_chat_with_held_lock():
635
+ # Регрессия (аудит 26.09.2026): чат с ЗАНЯТЫМ локом вытеснялся из памяти
636
+ # вместе с локом — идущий маршрут писал в отвязанный state, а следующее
637
+ # сообщение создавало новый лок: два параллельных ответа и порча истории.
638
+ import asyncio as _asyncio
639
+ import lumen_chat_state as lcs
640
+
641
+ busy_id, idle_id = 999801, 999802
642
+ orig_target = lcs.PRUNED_CHAT_TARGET
643
+ orig_states = dict(lcs.chat_state)
644
+ lcs.PRUNED_CHAT_TARGET = 0
645
+ lcs.chat_state.clear()
646
+ lcs.chat_state.update({
647
+ busy_id: {"last_activity": 0.0, "history": []},
648
+ idle_id: {"last_activity": 1.0, "history": []},
649
+ })
650
+ real_lock = asyncio.Lock()
651
+ lcs._pending_chat_deletions.discard(busy_id)
652
+ lcs._pending_chat_deletions.discard(idle_id)
653
+ original_locks = bot._chat_locks
654
+ bot._chat_locks = {busy_id: real_lock, idle_id: asyncio.Lock()}
655
+ try:
656
+ async def run():
657
+ await real_lock.acquire()
658
+ bot._prune_old_chats()
659
+ _asyncio.run(run())
660
+ assert busy_id in lcs.chat_state, "чат с занятым локом вытеснять нельзя"
661
+ assert idle_id not in lcs.chat_state
662
+ assert busy_id not in lcs._pending_chat_deletions
663
+ assert idle_id in lcs._pending_chat_deletions
664
+ finally:
665
+ bot._chat_locks = original_locks
666
+ lcs.PRUNED_CHAT_TARGET = orig_target
667
+ lcs.chat_state.clear()
668
+ lcs.chat_state.update(orig_states)
669
+ lcs._pending_chat_deletions.discard(busy_id)
670
+ lcs._pending_chat_deletions.discard(idle_id)
671
+
672
+
673
  def test_process_media_group_buffers_cleanup_on_cancel():
674
  # Отмена во сне (рестарт/дренаж) — записи буфера и задачи уходят, а не висят вечно.
675
  bot._mg_buffers["mgcancel"] = ["m1"]
tests/test_lumen_router_config.py CHANGED
@@ -638,3 +638,50 @@ def test_fish_audio_disabled_after_free_tier_gone():
638
  # Зеркало снято с бесплатного каталога OpenRouter — первая попытка в inline_tts
639
  # пропускается флагом, сама функция оставлена для тестов/возврата.
640
  assert lumen_router_config.FISH_AUDIO_ENABLED is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
  # Зеркало снято с бесплатного каталога OpenRouter — первая попытка в inline_tts
639
  # пропускается флагом, сама функция оставлена для тестов/возврата.
640
  assert lumen_router_config.FISH_AUDIO_ENABLED is False
641
+
642
+
643
+ # ─────────────── исчерпавшая квота больше не тратит попытку (аудит 26.09.2026) ───────────────
644
+
645
+ def test_or_route_skips_models_marked_exhausted():
646
+ import bot
647
+ bot.GLOBAL_QUOTA.setdefault("openrouter", {})["probe/exhausted:free"] = {"used": 0, "exhausted_at": 123.0}
648
+ try:
649
+ route = lumen_router_config._or_route(["probe/exhausted:free", "nvidia/nemotron-3.5-lightning:free"])
650
+ assert [m for _, m in route] == ["nvidia/nemotron-3.5-lightning:free"]
651
+ finally:
652
+ bot.GLOBAL_QUOTA["openrouter"].pop("probe/exhausted:free", None)
653
+
654
+
655
+ def test_gemini_and_groq_routes_skip_exhausted_models():
656
+ import bot
657
+ bot.GLOBAL_QUOTA.setdefault("gemini", {})["gemini-probe-dead"] = {"used": 0, "exhausted_at": 123.0}
658
+ bot.GLOBAL_QUOTA.setdefault("groq", {})["qwen/probe-dead"] = {"used": 0, "exhausted_at": 123.0}
659
+ try:
660
+ assert [m for _, m in lumen_router_config._gemini_route(["gemini-probe-dead", "gemini-3.8-flash"])] == ["gemini-3.8-flash"]
661
+ assert [m for _, m in lumen_router_config._groq_route(["qwen/probe-dead", "qwen/qwen3.8-27b"])] == ["qwen/qwen3.8-27b"]
662
+ finally:
663
+ bot.GLOBAL_QUOTA["gemini"].pop("gemini-probe-dead", None)
664
+ bot.GLOBAL_QUOTA["groq"].pop("qwen/probe-dead", None)
665
+
666
+
667
+ def test_route_never_becomes_empty_when_all_models_exhausted():
668
+ # Пустой маршрут = мгновенный отказ вместо внятного сообщения о лимите,
669
+ # поэтому последний вариант оставляем.
670
+ import bot
671
+ bot.GLOBAL_QUOTA.setdefault("openrouter", {})["probe/all-dead:free"] = {"used": 0, "exhausted_at": 123.0}
672
+ try:
673
+ assert lumen_router_config._or_route(["probe/all-dead:free"]) == [("openrouter", "probe/all-dead:free")]
674
+ finally:
675
+ bot.GLOBAL_QUOTA["openrouter"].pop("probe/all-dead:free", None)
676
+
677
+
678
+ def test_successful_usage_clears_exhausted_mark():
679
+ # Метка снимается успешным ответом, иначе после сброса суток или починки
680
+ # провайдера модель навсегда выпадала бы из роутинга.
681
+ import bot
682
+ bot.GLOBAL_QUOTA.setdefault("openrouter", {})["probe/recover:free"] = {"used": 3, "exhausted_at": 123.0}
683
+ try:
684
+ bot._record_quota_usage("openrouter", "probe/recover:free")
685
+ assert [m for _, m in lumen_router_config._or_route(["probe/recover:free"])] == ["probe/recover:free"]
686
+ finally:
687
+ bot.GLOBAL_QUOTA["openrouter"].pop("probe/recover:free", None)