Spaces:
Running
Running
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 +14 -4
- lumen_router_config.py +25 -4
- tests/test_bot_state.py +39 -0
- tests/test_lumen_router_config.py +47 -0
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 |
-
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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)
|