Spaces:
Running
Running
SilverElixir commited on
Commit ·
168044d
1
Parent(s): a5fc83f
Align daily quota accounting across draw TTS and 429 paths
Browse files- lumen_commands.py +15 -2
- lumen_routes.py +7 -2
- lumen_streaming.py +4 -1
- tests/test_bot_commands.py +55 -0
- tests/test_bot_routes.py +25 -0
- tests/test_bot_streaming.py +24 -0
- tests/test_bot_tts.py +29 -0
lumen_commands.py
CHANGED
|
@@ -65,6 +65,18 @@ async def cmd_start(message: Message) -> None:
|
|
| 65 |
|
| 66 |
async def inline_draw(message: Message, prompt: str) -> None:
|
| 67 |
import bot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_generating_image"))
|
| 69 |
try:
|
| 70 |
session = await bot._get_http_session()
|
|
@@ -129,6 +141,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 129 |
if sent is None:
|
| 130 |
raise RuntimeError("Telegram send_photo failed: connection timeout or proxy unavailable")
|
| 131 |
# Суточный счётчик /stats: картинка ушла пользователю.
|
|
|
|
| 132 |
bot._record_stats_event("answers_sent")
|
| 133 |
else:
|
| 134 |
if rate_limited:
|
|
@@ -395,8 +408,8 @@ async def inline_tts(message: Message, text: str) -> None:
|
|
| 395 |
)
|
| 396 |
if sent is None:
|
| 397 |
raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
|
| 398 |
-
|
| 399 |
-
|
| 400 |
bot._record_stats_event("answers_sent")
|
| 401 |
if shortened:
|
| 402 |
# Обрезка по общему дедлайну — говорим прямо, что озвучено начало.
|
|
|
|
| 65 |
|
| 66 |
async def inline_draw(message: Message, prompt: str) -> None:
|
| 67 |
import bot
|
| 68 |
+
# Дневной лимит пользователя, как у озвучки: проба без создания.
|
| 69 |
+
uid = bot._user_key_for_message(message)
|
| 70 |
+
_draw_entry = bot._user_daily_peek(uid)
|
| 71 |
+
if bot._user_daily_total_exhausted(uid, _draw_entry):
|
| 72 |
+
hours, mins = bot._user_daily_reset_in()
|
| 73 |
+
bot._record_stats_event("daily_limit_denials")
|
| 74 |
+
await bot._safe_reply(message, bot._t(
|
| 75 |
+
message.chat.id, "user_daily_total",
|
| 76 |
+
used=(_draw_entry or {}).get("total", 0), limit=bot._user_daily_limit(uid, "total", _draw_entry),
|
| 77 |
+
hours=hours, mins=mins,
|
| 78 |
+
))
|
| 79 |
+
return
|
| 80 |
status = await bot._tg_call(message.reply, bot._t(message.chat.id, "status_generating_image"))
|
| 81 |
try:
|
| 82 |
session = await bot._get_http_session()
|
|
|
|
| 141 |
if sent is None:
|
| 142 |
raise RuntimeError("Telegram send_photo failed: connection timeout or proxy unavailable")
|
| 143 |
# Суточный счётчик /stats: картинка ушла пользователю.
|
| 144 |
+
bot._record_user_daily(uid)
|
| 145 |
bot._record_stats_event("answers_sent")
|
| 146 |
else:
|
| 147 |
if rate_limited:
|
|
|
|
| 408 |
)
|
| 409 |
if sent is None:
|
| 410 |
raise RuntimeError("Telegram send_voice failed: connection timeout or proxy unavailable")
|
| 411 |
+
# Каждый чанк — отдельный синтез за квоту: списываем поштучно.
|
| 412 |
+
bot._record_user_daily(uid, tts=True)
|
| 413 |
bot._record_stats_event("answers_sent")
|
| 414 |
if shortened:
|
| 415 |
# Обрезка по общему дедлайну — говорим прямо, что озвучено начало.
|
lumen_routes.py
CHANGED
|
@@ -835,8 +835,13 @@ async def ask_gemini(
|
|
| 835 |
log.warning("[gemini] Model %s quota exhausted (429). Switching to %s", curr_model_id, next_model)
|
| 836 |
curr_model_id = next_model
|
| 837 |
continue
|
| 838 |
-
|
| 839 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
|
| 841 |
# Остальные исходы — одна попытка и сразу следующая модель, без ретраев.
|
| 842 |
next_model = bot._next_fallback_model(tried_models, chain)
|
|
|
|
| 835 |
log.warning("[gemini] Model %s quota exhausted (429). Switching to %s", curr_model_id, next_model)
|
| 836 |
curr_model_id = next_model
|
| 837 |
continue
|
| 838 |
+
if quota_exhausted_models:
|
| 839 |
+
log.warning("[gemini] All Gemini models in route exhausted their quota (429): %s", ", ".join(quota_exhausted_models))
|
| 840 |
+
raise bot.GeminiAllModelsExhaustedError(quota_exhausted_models) from exc
|
| 841 |
+
# Одни минутные всплески: суточного исчерпания не было — отдаём
|
| 842 |
+
# последний 429 как есть, а не пустой "исчерпано всё".
|
| 843 |
+
log.warning("[gemini] All Gemini models in route rate-limited (429), no daily exhaustion.")
|
| 844 |
+
raise
|
| 845 |
|
| 846 |
# Остальные исходы — одна попытка и сразу следующая модель, без ретраев.
|
| 847 |
next_model = bot._next_fallback_model(tried_models, chain)
|
lumen_streaming.py
CHANGED
|
@@ -393,7 +393,10 @@ async def _run_streaming_reply(
|
|
| 393 |
try:
|
| 394 |
_txt = bot._error_text(exc).strip() or exc.__class__.__name__
|
| 395 |
if bot._classify_model_error(bot._error_status(exc, _txt), _txt) == "rate_limit":
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
bot._mark_quota_exhausted(provider, model_id)
|
| 398 |
else:
|
| 399 |
bot._mark_rate_limited(provider, model_id)
|
|
|
|
| 393 |
try:
|
| 394 |
_txt = bot._error_text(exc).strip() or exc.__class__.__name__
|
| 395 |
if bot._classify_model_error(bot._error_status(exc, _txt), _txt) == "rate_limit":
|
| 396 |
+
# Дневную квоту опознаём предикатом провайдера, как текстовый
|
| 397 |
+
# путь: общий account-wide ловит только OpenRouter-тексты.
|
| 398 |
+
_daily = bot._is_gemini_daily_quota(_txt) if provider == "gemini" else bot._is_account_wide_or_rate_limit(_txt.lower())
|
| 399 |
+
if _daily:
|
| 400 |
bot._mark_quota_exhausted(provider, model_id)
|
| 401 |
else:
|
| 402 |
bot._mark_rate_limited(provider, model_id)
|
tests/test_bot_commands.py
CHANGED
|
@@ -584,6 +584,61 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
|
|
| 584 |
bot.chat_state.pop(chat_id, None)
|
| 585 |
|
| 586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
| 588 |
# Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
|
| 589 |
# общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
|
|
|
|
| 584 |
bot.chat_state.pop(chat_id, None)
|
| 585 |
|
| 586 |
|
| 587 |
+
def test_inline_draw_refuses_when_user_total_limit_exhausted(monkeypatch):
|
| 588 |
+
# /draw без дневного лимита обходился: исчерпанный total — отказ до генерации.
|
| 589 |
+
from types import SimpleNamespace
|
| 590 |
+
incoming = _FakeIncomingMessage(999491)
|
| 591 |
+
incoming.message_id = 1
|
| 592 |
+
incoming.from_user = SimpleNamespace(id=777021)
|
| 593 |
+
monkeypatch.setattr(bot, "OWNER_ID", 1)
|
| 594 |
+
monkeypatch.setattr(bot, "DAILY_USER_MESSAGE_LIMIT", 2)
|
| 595 |
+
bot._record_user_daily(777021)
|
| 596 |
+
bot._record_user_daily(777021)
|
| 597 |
+
|
| 598 |
+
async def fail_generate(session, model_id, prompt, timeout_sec=None):
|
| 599 |
+
raise AssertionError("generation must not run for an exhausted user")
|
| 600 |
+
|
| 601 |
+
monkeypatch.setattr(bot, "_pollinations_text_to_image", fail_generate)
|
| 602 |
+
replies = []
|
| 603 |
+
|
| 604 |
+
async def fake_safe_reply(msg, text, **kwargs):
|
| 605 |
+
replies.append(text)
|
| 606 |
+
|
| 607 |
+
monkeypatch.setattr(bot, "_safe_reply", fake_safe_reply)
|
| 608 |
+
try:
|
| 609 |
+
asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
|
| 610 |
+
assert len(replies) == 1
|
| 611 |
+
assert bot._user_daily_entry(777021)["total"] == 2
|
| 612 |
+
finally:
|
| 613 |
+
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777021", None)
|
| 614 |
+
bot.chat_state.pop(999491, None)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def test_inline_draw_records_user_daily_on_success(monkeypatch):
|
| 618 |
+
# Успешная картинка растит total, как озвучка.
|
| 619 |
+
from types import SimpleNamespace
|
| 620 |
+
incoming = _FakeIncomingMessage(999492)
|
| 621 |
+
incoming.message_id = 1
|
| 622 |
+
incoming.from_user = SimpleNamespace(id=777022)
|
| 623 |
+
monkeypatch.setattr(bot, "OWNER_ID", 1)
|
| 624 |
+
|
| 625 |
+
async def fake_generate(session, model_id, prompt, timeout_sec=None):
|
| 626 |
+
return b"\x89PNG fake bytes"
|
| 627 |
+
|
| 628 |
+
class _FakePhotoBot:
|
| 629 |
+
async def send_photo(self, **kwargs):
|
| 630 |
+
return SimpleNamespace()
|
| 631 |
+
|
| 632 |
+
monkeypatch.setattr(bot, "_pollinations_text_to_image", fake_generate)
|
| 633 |
+
monkeypatch.setattr(bot, "bot", _FakePhotoBot())
|
| 634 |
+
try:
|
| 635 |
+
asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
|
| 636 |
+
assert bot._user_daily_entry(777022)["total"] == 1
|
| 637 |
+
finally:
|
| 638 |
+
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777022", None)
|
| 639 |
+
bot.chat_state.pop(999492, None)
|
| 640 |
+
|
| 641 |
+
|
| 642 |
def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
|
| 643 |
# Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
|
| 644 |
# общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
|
tests/test_bot_routes.py
CHANGED
|
@@ -927,6 +927,31 @@ def test_ask_gemini_raises_all_models_exhausted_when_entire_chain_429s():
|
|
| 927 |
bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash", None)
|
| 928 |
|
| 929 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
def test_ask_gemini_falls_back_to_next_model_on_timeout():
|
| 931 |
# Прежняя версия использовала блокирующий time.sleep в async-фейке и один и тот
|
| 932 |
# же текст ответа для обеих моделей: таймаут никогда не срабатывал, фолбэка не
|
|
|
|
| 927 |
bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash", None)
|
| 928 |
|
| 929 |
|
| 930 |
+
def test_ask_gemini_minute_429s_do_not_raise_empty_exhausted():
|
| 931 |
+
# Одни минутные всплески: суточного исчерпания не было — последний 429
|
| 932 |
+
# идёт как есть, а не пустой "исчерпано всё".
|
| 933 |
+
chat_id = 999017
|
| 934 |
+
|
| 935 |
+
class _Minute429(Exception):
|
| 936 |
+
status_code = 429
|
| 937 |
+
|
| 938 |
+
def fake_generate_content(*, model, contents, config=None):
|
| 939 |
+
raise _Minute429("RESOURCE_EXHAUSTED: quota exceeded for quota metric Generate requests per minute")
|
| 940 |
+
|
| 941 |
+
fake_client = MagicMock()
|
| 942 |
+
fake_client.aio.models.generate_content = AsyncMock(side_effect=fake_generate_content)
|
| 943 |
+
original_client = bot.client
|
| 944 |
+
bot.client = fake_client
|
| 945 |
+
try:
|
| 946 |
+
with pytest.raises(_Minute429):
|
| 947 |
+
asyncio.run(bot.ask_gemini(chat_id, "Привет", model_chain=["gemini-3.6-flash", "gemini-2.5-flash"]))
|
| 948 |
+
finally:
|
| 949 |
+
bot.client = original_client
|
| 950 |
+
bot.chat_state.pop(chat_id, None)
|
| 951 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.6-flash", None)
|
| 952 |
+
bot.GLOBAL_QUOTA["gemini"].pop("gemini-2.5-flash", None)
|
| 953 |
+
|
| 954 |
+
|
| 955 |
def test_ask_gemini_falls_back_to_next_model_on_timeout():
|
| 956 |
# Прежняя версия использовала блокирующий time.sleep в async-фейке и один и тот
|
| 957 |
# же текст ответа для обеих моделей: таймаут никогда не срабатывал, фолбэка не
|
tests/test_bot_streaming.py
CHANGED
|
@@ -920,6 +920,30 @@ def test_send_text_pauses_between_chunks(monkeypatch):
|
|
| 920 |
bot.chat_state.pop(999114, None)
|
| 921 |
|
| 922 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
def test_rich_send_used_for_final_answer_with_table():
|
| 924 |
chat_id = 999401
|
| 925 |
incoming = _FakeIncomingMessage(chat_id)
|
|
|
|
| 920 |
bot.chat_state.pop(999114, None)
|
| 921 |
|
| 922 |
|
| 923 |
+
def test_gemini_streaming_daily_429_marks_exhausted_like_text_path():
|
| 924 |
+
# Стриминг опознавал суточную квоту чужим предикатом и ставил минутную
|
| 925 |
+
# остывку вместо метки до утра.
|
| 926 |
+
chat_id = 999116
|
| 927 |
+
model = bot.DEFAULT_GEMINI_MODEL
|
| 928 |
+
fake_stream = _fake_gemini_stream(raises=RuntimeError("429 Quota exceeded for the day"))
|
| 929 |
+
fake_client = MagicMock()
|
| 930 |
+
fake_client.aio.models.generate_content_stream = fake_stream
|
| 931 |
+
incoming = _FakeIncomingMessage(chat_id)
|
| 932 |
+
original_client = bot.client
|
| 933 |
+
bot.client = fake_client
|
| 934 |
+
bot.GLOBAL_QUOTA.setdefault("gemini", {}).pop(model, None)
|
| 935 |
+
try:
|
| 936 |
+
answer, _ = asyncio.run(bot._try_gemini_streaming(chat_id, "Привет!", incoming, model))
|
| 937 |
+
assert answer is None
|
| 938 |
+
entry = bot.GLOBAL_QUOTA["gemini"][model]
|
| 939 |
+
assert entry["exhausted_at"] is not None
|
| 940 |
+
assert lumen_router_config._is_quota_exhausted("gemini", model) is True
|
| 941 |
+
finally:
|
| 942 |
+
bot.client = original_client
|
| 943 |
+
bot.chat_state.pop(chat_id, None)
|
| 944 |
+
bot.GLOBAL_QUOTA["gemini"].pop(model, None)
|
| 945 |
+
|
| 946 |
+
|
| 947 |
def test_rich_send_used_for_final_answer_with_table():
|
| 948 |
chat_id = 999401
|
| 949 |
incoming = _FakeIncomingMessage(chat_id)
|
tests/test_bot_tts.py
CHANGED
|
@@ -533,3 +533,32 @@ def test_inline_tts_records_user_daily_on_success(monkeypatch):
|
|
| 533 |
bot.chat_state.pop(999989, None)
|
| 534 |
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None)
|
| 535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
bot.chat_state.pop(999989, None)
|
| 534 |
bot.GLOBAL_QUOTA["gemini"].pop("gemini-3.1-flash-tts-preview", None)
|
| 535 |
|
| 536 |
+
|
| 537 |
+
def test_inline_tts_charges_each_synthesized_chunk():
|
| 538 |
+
# Каждый чанк — отдельный синтез за квоту: списываем поштучно, а не раз за сообщение.
|
| 539 |
+
limit = bot.TTS_MAX_CHARS
|
| 540 |
+
part = "Предложение номер раз про интересные вещи. "
|
| 541 |
+
text = part * ((limit // len(part)) + 2)
|
| 542 |
+
assert len(text) > limit
|
| 543 |
+
|
| 544 |
+
incoming = _FakeIncomingMessage(999810)
|
| 545 |
+
incoming.message_id = 12360
|
| 546 |
+
incoming.from_user = SimpleNamespace(id=777015)
|
| 547 |
+
|
| 548 |
+
original_client = bot.client
|
| 549 |
+
original_bot = bot.bot
|
| 550 |
+
bot.client = _wav_client()
|
| 551 |
+
counting = _CountingVoiceBot()
|
| 552 |
+
bot.bot = counting
|
| 553 |
+
try:
|
| 554 |
+
asyncio.run(bot.inline_tts(incoming, text))
|
| 555 |
+
assert len(counting.voices) == 2
|
| 556 |
+
entry = bot.GLOBAL_QUOTA.get("user_daily", {}).get("777015")
|
| 557 |
+
assert entry is not None
|
| 558 |
+
assert entry["tts"] == 2 and entry["total"] == 2
|
| 559 |
+
finally:
|
| 560 |
+
bot.client = original_client
|
| 561 |
+
bot.bot = original_bot
|
| 562 |
+
bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777015", None)
|
| 563 |
+
bot.chat_state.pop(999810, None)
|
| 564 |
+
|