SilverElixir commited on
Commit
4b8d7ce
·
1 Parent(s): 5c09528

stats goes pro: hide idle models, per-provider totals and remaining quota

Browse files
Files changed (2) hide show
  1. lumen_commands.py +25 -21
  2. tests/test_bot_commands.py +45 -0
lumen_commands.py CHANGED
@@ -426,27 +426,31 @@ async def cmd_stats(message: Message) -> None:
426
  _stats_lang = bot._chat_lang(message.chat.id)
427
  _limitTag = _lang_t(_stats_lang, "stats_limit_used")
428
  _noData = _lang_t(_stats_lang, "stats_no_data")
429
- gemini_lines = [
430
- f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
431
- for mid, e in sorted(gemini_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
432
- ]
433
- gemini_text = "\n".join(gemini_lines) or _noData
434
-
435
- # OpenRouter — разбивка по моделям как у Gemini (калибровка 25.07.2026: одна суммарная цифра для отладки бесполезна; $-баланс убран — все модели :free).
436
- or_quota = bot.GLOBAL_QUOTA.get("openrouter", {})
437
- or_lines = [
438
- f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
439
- for mid, e in sorted(or_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
440
- ]
441
- or_text = "\n".join(or_lines) or _noData
442
-
443
- # Groq — та же разбивка по моделям (прямой провайдер с собственным дневным лимитом).
444
- groq_quota = bot.GLOBAL_QUOTA.get("groq", {})
445
- groq_lines = [
446
- f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
447
- for mid, e in sorted(groq_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
448
- ]
449
- groq_text = "\n".join(groq_lines) or _noData
 
 
 
 
450
 
451
  # Состояние прокси — из самого breaker'а (раньше команда лезла в четыре глобала напрямую).
452
  proxy_line = bot._tg_proxy_breaker.status_text()
 
426
  _stats_lang = bot._chat_lang(message.chat.id)
427
  _limitTag = _lang_t(_stats_lang, "stats_limit_used")
428
  _noData = _lang_t(_stats_lang, "stats_no_data")
429
+
430
+ def _quota_section(quota: dict, daily_limit: int | None) -> str:
431
+ # Показываем только модели с движением (расход или метка исчерпания) — нули по
432
+ # давно мёртвым моделям копятся в хранилище через рестарты и превращали вывод в простыню.
433
+ items = sorted(quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
434
+ used_total = sum(int(e.get("used") or 0) for _, e in items)
435
+ active = [(mid, e) for mid, e in items if (e.get("used") or 0) or e.get("exhausted_at")]
436
+ lines = [
437
+ f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
438
+ for mid, e in active
439
+ ]
440
+ idle = len(items) - len(active)
441
+ if idle:
442
+ lines.append(f" …и ещё {idle} без обращений")
443
+ total = f" Σ: {used_total}"
444
+ if daily_limit is not None:
445
+ total += f" / {daily_limit} (осталось {max(0, daily_limit - used_total)})"
446
+ lines.append(total)
447
+ return "\n".join(lines) or _noData
448
+
449
+ gemini_text = _quota_section(gemini_quota, None)
450
+ # OpenRouter — дневной лимит free-моделей 50 (с $10 — 1000, тогда остаток врёт в меньшую сторону).
451
+ or_text = _quota_section(bot.GLOBAL_QUOTA.get("openrouter", {}), 50)
452
+ # Groq — дневной лимит free-плана 1000 запросов (калибровка 21.09.2026).
453
+ groq_text = _quota_section(bot.GLOBAL_QUOTA.get("groq", {}), 1000)
454
 
455
  # Состояние прокси — из самого breaker'а (раньше команда лезла в четыре глобала напрямую).
456
  proxy_line = bot._tg_proxy_breaker.status_text()
tests/test_bot_commands.py CHANGED
@@ -7,6 +7,7 @@ from types import SimpleNamespace
7
  from unittest.mock import AsyncMock
8
  import asyncio
9
  import bot
 
10
  import lumen_limits
11
  import pytest
12
  import time
@@ -55,6 +56,50 @@ def test_cmd_logs_flushes_the_listeners_real_handlers_not_root():
55
  bot._LOG_LISTENER = original_listener
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def test_match_trigger_prefix_finds_draw_trigger():
59
  assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй"
60
 
 
7
  from unittest.mock import AsyncMock
8
  import asyncio
9
  import bot
10
+ import lumen_chat_state
11
  import lumen_limits
12
  import pytest
13
  import time
 
56
  bot._LOG_LISTENER = original_listener
57
 
58
 
59
+ def test_cmd_stats_hides_idle_models_and_shows_totals():
60
+ # Проф-вид /stats: нули по мёртвым моделям не мусорят, у каждого провайдера итог и остаток лимита.
61
+ chat_id = 999811
62
+ incoming = _FakeIncomingMessage(chat_id)
63
+ incoming.from_user = SimpleNamespace(id=777001)
64
+ sent = {}
65
+
66
+ async def fake_tg_call(method, *args, **kwargs):
67
+ sent["text"] = args[0] if args else kwargs.get("text", "")
68
+ return SimpleNamespace()
69
+
70
+ original_owner = bot.OWNER_ID
71
+ original_tg_call = bot._tg_call
72
+ real_quota = dict(bot.GLOBAL_QUOTA)
73
+ bot.OWNER_ID = 777001
74
+ bot._tg_call = fake_tg_call
75
+ bot.GLOBAL_QUOTA.clear()
76
+ bot.GLOBAL_QUOTA.update({
77
+ "gemini": {
78
+ "gemini-3.6-flash": {"used": 3, "exhausted_at": None},
79
+ "gemini-dead-model": {"used": 0, "exhausted_at": None},
80
+ },
81
+ "openrouter": {"nvidia/x:free": {"used": 5, "exhausted_at": None}},
82
+ "groq": {},
83
+ "quota_day": bot._current_quota_day(),
84
+ })
85
+ try:
86
+ asyncio.run(bot.cmd_stats(incoming))
87
+ text = sent["text"]
88
+ assert "gemini-3.6-flash: 3" in text
89
+ assert "gemini-dead-model" not in text
90
+ assert "без обращений" in text
91
+ assert "Σ: 3" in text
92
+ assert "Σ: 5 / 50 (осталось 45)" in text
93
+ assert "Σ: 0 / 1000 (осталось 1000)" in text
94
+ finally:
95
+ bot.OWNER_ID = original_owner
96
+ bot._tg_call = original_tg_call
97
+ bot.GLOBAL_QUOTA.clear()
98
+ bot.GLOBAL_QUOTA.update(real_quota)
99
+ bot.chat_state.pop(chat_id, None)
100
+ lumen_chat_state.chat_state.pop(chat_id, None)
101
+
102
+
103
  def test_match_trigger_prefix_finds_draw_trigger():
104
  assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй"
105