SilverElixir commited on
Commit
168044d
·
1 Parent(s): a5fc83f

Align daily quota accounting across draw TTS and 429 paths

Browse files
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
- bot._record_user_daily(uid, tts=True)
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
- log.warning("[gemini] All Gemini models in route exhausted their quota (429): %s", ", ".join(quota_exhausted_models))
839
- raise bot.GeminiAllModelsExhaustedError(quota_exhausted_models) from exc
 
 
 
 
 
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
- if bot._is_account_wide_or_rate_limit(_txt.lower()):
 
 
 
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
+