SilverElixir commited on
Commit
c836aa8
·
1 Parent(s): 077fbac

Merge near-duplicate router tests into parametrized cases

Browse files
Files changed (1) hide show
  1. tests/test_bot_routes.py +84 -209
tests/test_bot_routes.py CHANGED
@@ -20,40 +20,25 @@ from tests.bot_test_helpers import (
20
  )
21
 
22
 
23
- def test_classify_model_error_rate_limit_by_status():
24
- assert bot._classify_model_error(429, "") == "rate_limit"
25
-
26
-
27
- def test_classify_model_error_rate_limit_by_text():
28
- assert bot._classify_model_error(None, "quota exceeded") == "rate_limit"
29
-
30
-
31
- def test_classify_model_error_paid():
32
- assert bot._classify_model_error(402, "") == "paid"
33
-
34
-
35
- def test_classify_model_error_forbidden():
36
- assert bot._classify_model_error(403, "") == "forbidden"
37
-
38
-
39
- def test_classify_model_error_unavailable():
40
- assert bot._classify_model_error(404, "") == "unavailable"
41
-
42
-
43
- def test_classify_model_error_other_for_unknown():
44
- assert bot._classify_model_error(500, "some random error") == "other"
45
-
46
-
47
- def test_next_fallback_model_skips_tried():
48
- assert bot._next_fallback_model({"a"}, ["a", "b", "c"]) == "b"
49
-
50
-
51
- def test_next_fallback_model_all_tried_returns_none():
52
- assert bot._next_fallback_model({"a", "b", "c"}, ["a", "b", "c"]) is None
53
 
54
 
55
- def test_next_fallback_model_none_tried_returns_first():
56
- assert bot._next_fallback_model(set(), ["a", "b", "c"]) == "a"
 
 
 
 
 
57
 
58
 
59
  def test_error_status_reads_status_code_attribute():
@@ -211,13 +196,11 @@ def test_ask_gemini_scrubs_identity_leak_before_storing_history():
211
  bot.chat_state.pop(chat_id, None)
212
 
213
 
214
- def test_ask_openrouter_text_empty_model_chain_fallback_is_not_dead_model():
 
215
  # РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
216
- # в ask_openrouter_text было "meta-llama/llama-3.3-70b-instruct:free" — та же
217
- # модель, что подтверждённо снята провайдером с бесплатного тира (см. README,
218
- # HTTP 404 "unavailable for free") и по этой же причине уже исключена из
219
- # _OR_LIGHT_ORDER/_OR_HEAVY_ORDER. Проверяем, что дефолт теперь ссылается на
220
- # актуальный _OR_LIGHT_ORDER, а не на захардкоженную мёртвую модель.
221
  chat_id = 999401
222
 
223
  calls = []
@@ -229,8 +212,13 @@ def test_ask_openrouter_text_empty_model_chain_fallback_is_not_dead_model():
229
  original = bot._or_chat_completion_with_fallback
230
  bot._or_chat_completion_with_fallback = fake_or_fallback
231
  try:
232
- asyncio.run(bot.ask_openrouter_text(chat_id, "привет", model_chain=[]))
233
- assert calls[0] == [bot._OR_LIGHT_ORDER[0]]
 
 
 
 
 
234
  assert "meta-llama/llama-3.3-70b-instruct:free" not in calls[0]
235
  finally:
236
  bot._or_chat_completion_with_fallback = original
@@ -388,15 +376,20 @@ def test_extract_gemini_answer_reports_non_malformed_block_without_retry_state()
388
  assert "SAFETY" in ans
389
 
390
 
391
- def test_extract_gemini_answer_falls_through_when_retry_raises():
392
- # Повтор после битого вызова сам упал (а не просто не влез в бюджет) — тоже
393
- # отдаём пусто, чтобы маршрут ушёл на следующую модель. Раньше пользователь
394
- # получал "[Ответ заблокирован...]" как готовый ответ и цепочка вставала.
 
395
  async def retry_raises(*, model, contents, config=None):
396
  raise RuntimeError("transient 500 on retry")
397
 
 
 
 
 
398
  fake_client = MagicMock()
399
- fake_client.aio.models.generate_content = retry_raises
400
  original_client = bot.client
401
  bot.client = fake_client
402
  try:
@@ -982,11 +975,14 @@ def test_ask_gemini_raises_when_route_budget_exceeded():
982
  bot.chat_state.pop(chat_id, None)
983
 
984
 
985
- def test_or_chat_completion_with_fallback_switches_model_on_rate_limit():
 
 
 
986
  async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
987
  model = json_body["model"]
988
  if model == "model-a":
989
- raise bot.OpenRouterAPIError("rate limit exceeded", status_code=429)
990
  return {"choices": [{"message": {"content": "ответ от model-b"}}]}
991
 
992
  original = bot._or_request
@@ -1000,49 +996,6 @@ def test_or_chat_completion_with_fallback_switches_model_on_rate_limit():
1000
  bot._or_request = original
1001
 
1002
 
1003
- def test_or_chat_completion_with_fallback_switches_model_on_permanent_looking_error():
1004
- # Даже "постоянная" на вид ошибка (403 forbidden) не должна обрывать переход
1005
- # к следующей модели — при attempts_per_model=1 переход к следующей модели
1006
- # происходит независимо от классификации (см. комментарий в самой функции).
1007
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1008
- model = json_body["model"]
1009
- if model == "model-a":
1010
- raise bot.OpenRouterAPIError("forbidden", status_code=403)
1011
- return {"choices": [{"message": {"content": "ответ от model-b"}}]}
1012
-
1013
- original = bot._or_request
1014
- bot._or_request = fake_or_request
1015
- try:
1016
- messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
1017
- answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a"))
1018
- assert answer == "ответ от model-b"
1019
- assert used == "model-b"
1020
- finally:
1021
- bot._or_request = original
1022
-
1023
-
1024
- def test_ask_openrouter_multimodal_empty_model_chain_fallback_is_current_vision_order():
1025
- # РЕГРЕССИЯ (аудит техдолга): раньше здесь стоял захардкоженный литерал
1026
- # "nvidia/nemotron-nano-12b-v2-vl:free" — тот же класс бага, что уже был найден
1027
- # и исправлен в ask_openrouter_text (см. test_ask_openrouter_text_empty_model_chain_
1028
- # fallback_is_not_dead_model выше). Теперь дефолт ссылается на _OR_VISION_ORDER[0].
1029
- chat_id = 999410
1030
- calls = []
1031
-
1032
- async def fake_or_fallback(messages, trial_models, primary_model_id, **kwargs):
1033
- calls.append(trial_models)
1034
- return "ответ", trial_models[0]
1035
-
1036
- original = bot._or_chat_completion_with_fallback
1037
- bot._or_chat_completion_with_fallback = fake_or_fallback
1038
- try:
1039
- asyncio.run(bot.ask_openrouter_multimodal(chat_id, "привет", (b"fake", "image/jpeg"), "photo.jpg", model_chain=[]))
1040
- assert calls[0] == [bot._OR_VISION_ORDER[0]]
1041
- finally:
1042
- bot._or_chat_completion_with_fallback = original
1043
- bot.chat_state.pop(chat_id, None)
1044
-
1045
-
1046
  def test_ask_openrouter_multimodal_sends_all_album_images_not_just_first():
1047
  # Внешний аудит: уходила только первая фотография альбома, остальные 2–9 молча терялись.
1048
  chat_id = 999411
@@ -1141,26 +1094,27 @@ def test_or_chat_completion_with_fallback_no_longer_accepts_attempts_per_model()
1141
  assert "attempts_per_model" not in sig.parameters
1142
 
1143
 
1144
- def test_or_request_scrubs_api_key_from_network_exception_message():
1145
- class _FakeSessionRaisingWithKeyInMessage:
 
 
 
 
 
 
 
1146
  def request(self, *args, **kwargs):
1147
- raise RuntimeError("connection failed, headers were: Authorization: Bearer fake-secret-or-key-123")
1148
 
1149
  async def fake_get_http_session():
1150
- return _FakeSessionRaisingWithKeyInMessage()
1151
 
1152
- original_get_session = bot._get_http_session
1153
- original_key = bot.OPENROUTER_API_KEY
1154
- bot._get_http_session = fake_get_http_session
1155
- bot.OPENROUTER_API_KEY = "fake-secret-or-key-123"
1156
- try:
1157
- with pytest.raises(bot.OpenRouterAPIError) as exc_info:
1158
- asyncio.run(bot._or_request("chat/completions", "POST", json_body={"model": "x"}))
1159
- assert "fake-secret-or-key-123" not in str(exc_info.value)
1160
- assert "<KEY>" in str(exc_info.value)
1161
- finally:
1162
- bot._get_http_session = original_get_session
1163
- bot.OPENROUTER_API_KEY = original_key
1164
 
1165
 
1166
  def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog):
@@ -1371,23 +1325,6 @@ def test_groq_request_requires_key(monkeypatch):
1371
  asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
1372
 
1373
 
1374
- def test_groq_request_scrubs_api_key_from_network_exception_message(monkeypatch):
1375
- # Тот же defense-in-depth, что у _or_request: ключ не должен светиться в тексте ошибок.
1376
- class _FakeSessionRaisingWithKey:
1377
- def request(self, *args, **kwargs):
1378
- raise RuntimeError("connection failed, headers: Bearer fake-groq-key-123")
1379
-
1380
- async def fake_get_http_session():
1381
- return _FakeSessionRaisingWithKey()
1382
-
1383
- monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
1384
- monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-groq-key-123")
1385
- with pytest.raises(bot.GroqAPIError) as exc_info:
1386
- asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
1387
- assert "fake-groq-key-123" not in str(exc_info.value)
1388
- assert "<KEY>" in str(exc_info.value)
1389
-
1390
-
1391
  def test_route_error_reply_text_maps_groq_error():
1392
  # Ошибка Groq — пользовательский текст через общую классификацию, без сырого API.
1393
  exc = bot.GroqAPIError("Rate limit reached", status_code=429)
@@ -1569,53 +1506,28 @@ def test_transcribe_audio_capped_by_own_limit(monkeypatch):
1569
  assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
1570
 
1571
 
1572
- def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
 
 
 
 
1573
  # Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
1574
  import lumen_chat_state
1575
  from collections import deque
1576
  chat_id = 999811
1577
 
1578
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1579
- return "ответ", trial_models[0]
1580
-
1581
- async def fake_summarize(text):
1582
- return "итог: погода и коты"
1583
-
1584
- monkeypatch.setattr(bot, "_or_chat_completion_with_fallback", fake_fallback)
1585
- monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
1586
- state = bot.get_state(chat_id)
1587
- state["history"] = [
1588
- {"role": "user" if i % 2 == 0 else "assistant", "content": f"сообщение {i}"}
1589
- for i in range(105)
1590
- ]
1591
- state["ctx"] = deque()
1592
- try:
1593
- asyncio.run(bot.ask_openrouter_text(chat_id, "новый вопрос", model_chain=["m1"]))
1594
- history = bot.chat_state[chat_id]["history"]
1595
- assert len(history) == 81
1596
- assert history[0]["content"].startswith("[Ранее в диалоге]")
1597
- assert "погода" in history[0]["content"]
1598
- assert history[-2:] == [
1599
- {"role": "user", "content": "новый вопрос"},
1600
- {"role": "assistant", "content": "ответ"},
1601
- ]
1602
- finally:
1603
- bot.chat_state.pop(chat_id, None)
1604
-
1605
-
1606
- def test_ask_groq_text_trims_history_with_summary_not_silent_cut(monkeypatch):
1607
- # Тот же молчаливый срез жил в ask_groq_text — фиксим парой (аудит A4-05: везде _trim_history).
1608
- import lumen_chat_state
1609
- from collections import deque
1610
- chat_id = 999812
1611
 
1612
  async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1613
- return {"choices": [{"message": {"content": "ответ groq"}}]}
 
 
1614
 
1615
  async def fake_summarize(text):
1616
  return "итог: погода и коты"
1617
 
1618
- monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
1619
  monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
1620
  state = bot.get_state(chat_id)
1621
  state["history"] = [
@@ -1624,14 +1536,14 @@ def test_ask_groq_text_trims_history_with_summary_not_silent_cut(monkeypatch):
1624
  ]
1625
  state["ctx"] = deque()
1626
  try:
1627
- asyncio.run(bot.ask_groq_text(chat_id, "новый вопрос", model_chain=["m1"]))
1628
  history = bot.chat_state[chat_id]["history"]
1629
  assert len(history) == 81
1630
  assert history[0]["content"].startswith("[Ранее в диалоге]")
1631
  assert "погода" in history[0]["content"]
1632
  assert history[-2:] == [
1633
  {"role": "user", "content": "новый вопрос"},
1634
- {"role": "assistant", "content": "ответ groq"},
1635
  ]
1636
  finally:
1637
  bot.chat_state.pop(chat_id, None)
@@ -1685,26 +1597,6 @@ def test_run_route_marks_unsupported_media_for_text_fallback(monkeypatch):
1685
  bot.chat_state.pop(chat_id, None)
1686
 
1687
 
1688
- def test_extract_gemini_answer_empty_retry_goes_to_next_model():
1689
- # Аудит A4-10: повтор прошёл, но вернул пусто без исчерпания бюджета — это
1690
- # повод на следующую модель, а не заглушка блокировки как успех.
1691
- async def retry_empty(*, model, contents, config=None):
1692
- return _FakeGeminiResponse(text="", candidates=[])
1693
-
1694
- fake_client = MagicMock()
1695
- fake_client.aio.models.generate_content = retry_empty
1696
- original_client = bot.client
1697
- bot.client = fake_client
1698
- try:
1699
- resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="MALFORMED_FUNCTION_CALL")])
1700
- ans = asyncio.run(bot._extract_gemini_answer_text(
1701
- resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
1702
- ))
1703
- assert ans == ""
1704
- finally:
1705
- bot.client = original_client
1706
-
1707
-
1708
  def test_run_route_cleans_placeholder_on_cancel(monkeypatch):
1709
  # Аудит A4-06: отмена посреди ответа оставляла "…" в чате навсегда.
1710
  placeholder = _FakeSentMessage()
@@ -1882,18 +1774,21 @@ def test_daily_limit_denial_counted_and_refused(rate_guard_setup, monkeypatch):
1882
  bot.chat_state.pop(123, None)
1883
 
1884
 
1885
- def test_gemini_success_logs_token_usage_when_api_returns_it(caplog):
 
1886
  # Что защищает: единственную точку калибровки лимитов по логам для Gemini
1887
  # (usage_metadata.prompt_token_count/candidates_token_count/total_token_count).
1888
  # Регрессия: тихий дроп usage при рефакторинге ask_gemini — счётчики пропадут из
1889
  # логов, лимиты не на чем калибровать. Старые тесты проверяют только текст/историю.
 
1890
  import logging
1891
  from types import SimpleNamespace
1892
  chat_id = 999901
1893
 
1894
  def fake_generate_content(*, model, contents, config=None):
1895
  resp = _FakeGeminiResponse(text="Привет!")
1896
- resp.usage_metadata = SimpleNamespace(prompt_token_count=10, candidates_token_count=20, total_token_count=30)
 
1897
  return resp
1898
 
1899
  fake_client = MagicMock()
@@ -1905,33 +1800,13 @@ def test_gemini_success_logs_token_usage_when_api_returns_it(caplog):
1905
  answer = asyncio.run(bot.ask_gemini(chat_id, "Привет"))
1906
  assert answer == "Привет!"
1907
  usage_lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("[usage]")]
1908
- assert len(usage_lines) == 1
1909
- line = usage_lines[0]
1910
- assert f"chat={chat_id}" in line and "provider=gemini" in line
1911
- assert "prompt=10" in line and "completion=20" in line and "total=30" in line
1912
- finally:
1913
- bot.client = original_client
1914
- bot.chat_state.pop(chat_id, None)
1915
- caplog.clear()
1916
-
1917
-
1918
- def test_gemini_success_logs_no_usage_line_without_metadata(caplog):
1919
- # Пара к предыдущему: если API usage не вернуло — строка [usage] не пишется,
1920
- # лог не засоряется пустышками. Старые тесты такого не проверяют.
1921
- import logging
1922
- chat_id = 999902
1923
-
1924
- def fake_generate_content(*, model, contents, config=None):
1925
- return _FakeGeminiResponse(text="Привет!")
1926
-
1927
- fake_client = MagicMock()
1928
- fake_client.aio.models.generate_content = AsyncMock(side_effect=fake_generate_content)
1929
- original_client = bot.client
1930
- bot.client = fake_client
1931
- try:
1932
- with caplog.at_level(logging.INFO, logger="bot"):
1933
- asyncio.run(bot.ask_gemini(chat_id, "Привет"))
1934
- assert not [r for r in caplog.records if r.getMessage().startswith("[usage]")]
1935
  finally:
1936
  bot.client = original_client
1937
  bot.chat_state.pop(chat_id, None)
 
20
  )
21
 
22
 
23
+ @pytest.mark.parametrize(("status", "text", "expected"), [
24
+ (429, "", "rate_limit"),
25
+ (None, "quota exceeded", "rate_limit"),
26
+ (402, "", "paid"),
27
+ (403, "", "forbidden"),
28
+ (404, "", "unavailable"),
29
+ (500, "some random error", "other"),
30
+ ])
31
+ def test_classify_model_error(status, text, expected):
32
+ assert bot._classify_model_error(status, text) == expected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
+ @pytest.mark.parametrize(("tried", "chain", "expected"), [
36
+ ({"a"}, ["a", "b", "c"], "b"),
37
+ ({"a", "b", "c"}, ["a", "b", "c"], None),
38
+ (set(), ["a", "b", "c"], "a"),
39
+ ])
40
+ def test_next_fallback_model(tried, chain, expected):
41
+ assert bot._next_fallback_model(tried, chain) == expected
42
 
43
 
44
  def test_error_status_reads_status_code_attribute():
 
196
  bot.chat_state.pop(chat_id, None)
197
 
198
 
199
+ @pytest.mark.parametrize("mode", ["text", "vision"])
200
+ def test_empty_model_chain_falls_back_to_current_order_head(mode):
201
  # РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
202
+ # стоял захардкоженный литерал мёртвой модели; теперь дефолт ссылается на голову
203
+ # актуального ORDER-списка (_OR_LIGHT_ORDER для текста, _OR_VISION_ORDER для vision).
 
 
 
204
  chat_id = 999401
205
 
206
  calls = []
 
212
  original = bot._or_chat_completion_with_fallback
213
  bot._or_chat_completion_with_fallback = fake_or_fallback
214
  try:
215
+ if mode == "text":
216
+ asyncio.run(bot.ask_openrouter_text(chat_id, "привет", model_chain=[]))
217
+ expected = [bot._OR_LIGHT_ORDER[0]]
218
+ else:
219
+ asyncio.run(bot.ask_openrouter_multimodal(chat_id, "привет", (b"fake", "image/jpeg"), "photo.jpg", model_chain=[]))
220
+ expected = [bot._OR_VISION_ORDER[0]]
221
+ assert calls[0] == expected
222
  assert "meta-llama/llama-3.3-70b-instruct:free" not in calls[0]
223
  finally:
224
  bot._or_chat_completion_with_fallback = original
 
376
  assert "SAFETY" in ans
377
 
378
 
379
+ @pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
380
+ def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
381
+ # Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
382
+ # вернул пусто — отдаём п��сто, чтобы маршрут ушёл на следующую модель.
383
+ # Раньше пользователь получал "[Ответ заблокирован...]" как готовый ответ.
384
  async def retry_raises(*, model, contents, config=None):
385
  raise RuntimeError("transient 500 on retry")
386
 
387
+ async def retry_empty(*, model, contents, config=None):
388
+ return _FakeGeminiResponse(text="", candidates=[])
389
+
390
+ retry_fn = retry_raises if retry_outcome == "raises" else retry_empty
391
  fake_client = MagicMock()
392
+ fake_client.aio.models.generate_content = retry_fn
393
  original_client = bot.client
394
  bot.client = fake_client
395
  try:
 
975
  bot.chat_state.pop(chat_id, None)
976
 
977
 
978
+ @pytest.mark.parametrize(("status_code", "message"), [(429, "rate limit exceeded"), (403, "forbidden")])
979
+ def test_or_chat_completion_with_fallback_switches_model(status_code, message):
980
+ # И минутный 429, и "постоянная" на вид 403 не обрывают переход к следующей
981
+ # модели: при attempts_per_model=1 переход происходит независимо от классификации.
982
  async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
983
  model = json_body["model"]
984
  if model == "model-a":
985
+ raise bot.OpenRouterAPIError(message, status_code=status_code)
986
  return {"choices": [{"message": {"content": "ответ от model-b"}}]}
987
 
988
  original = bot._or_request
 
996
  bot._or_request = original
997
 
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  def test_ask_openrouter_multimodal_sends_all_album_images_not_just_first():
1000
  # Внешний аудит: уходила только первая фотография альбома, остальные 2–9 молча терялись.
1001
  chat_id = 999411
 
1094
  assert "attempts_per_model" not in sig.parameters
1095
 
1096
 
1097
+ @pytest.mark.parametrize(("request_name", "key_attr", "fake_key", "error_cls"), [
1098
+ ("_or_request", "OPENROUTER_API_KEY", "fake-secret-or-key-123", "OpenRouterAPIError"),
1099
+ ("_groq_request", "GROQ_API_KEY", "fake-groq-key-123", "GroqAPIError"),
1100
+ ])
1101
+ def test_provider_request_scrubs_api_key_from_network_exception_message(
1102
+ monkeypatch, request_name, key_attr, fake_key, error_cls,
1103
+ ):
1104
+ # Defense-in-depth у обоих провайдеров: ключ не светится в тексте ошибок.
1105
+ class _FakeSessionRaisingWithKey:
1106
  def request(self, *args, **kwargs):
1107
+ raise RuntimeError(f"connection failed, headers: Bearer {fake_key}")
1108
 
1109
  async def fake_get_http_session():
1110
+ return _FakeSessionRaisingWithKey()
1111
 
1112
+ monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
1113
+ monkeypatch.setattr(bot, key_attr, fake_key)
1114
+ with pytest.raises(getattr(bot, error_cls)) as exc_info:
1115
+ asyncio.run(getattr(bot, request_name)("chat/completions", "POST", json_body={"model": "x"}))
1116
+ assert fake_key not in str(exc_info.value)
1117
+ assert "<KEY>" in str(exc_info.value)
 
 
 
 
 
 
1118
 
1119
 
1120
  def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog):
 
1325
  asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
1326
 
1327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  def test_route_error_reply_text_maps_groq_error():
1329
  # Ошибка Groq — пользовательский текст через общую классификацию, без сырого API.
1330
  exc = bot.GroqAPIError("Rate limit reached", status_code=429)
 
1506
  assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
1507
 
1508
 
1509
+ @pytest.mark.parametrize(("ask_name", "stub_name", "answer"), [
1510
+ ("ask_openrouter_text", "_or_chat_completion_with_fallback", "ответ"),
1511
+ ("ask_groq_text", "_groq_request", "ответ groq"),
1512
+ ])
1513
+ def test_text_provider_trims_history_with_summary_not_silent_cut(monkeypatch, ask_name, stub_name, answer):
1514
  # Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
1515
  import lumen_chat_state
1516
  from collections import deque
1517
  chat_id = 999811
1518
 
1519
  async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
1520
+ return answer, trial_models[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
 
1522
  async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
1523
+ return {"choices": [{"message": {"content": answer}}]}
1524
+
1525
+ stubs = {"_or_chat_completion_with_fallback": fake_fallback, "_groq_request": fake_groq_request}
1526
 
1527
  async def fake_summarize(text):
1528
  return "итог: погода и коты"
1529
 
1530
+ monkeypatch.setattr(bot, stub_name, stubs[stub_name])
1531
  monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
1532
  state = bot.get_state(chat_id)
1533
  state["history"] = [
 
1536
  ]
1537
  state["ctx"] = deque()
1538
  try:
1539
+ asyncio.run(getattr(bot, ask_name)(chat_id, "новый вопрос", model_chain=["m1"]))
1540
  history = bot.chat_state[chat_id]["history"]
1541
  assert len(history) == 81
1542
  assert history[0]["content"].startswith("[Ранее в диалоге]")
1543
  assert "погода" in history[0]["content"]
1544
  assert history[-2:] == [
1545
  {"role": "user", "content": "новый вопрос"},
1546
+ {"role": "assistant", "content": answer},
1547
  ]
1548
  finally:
1549
  bot.chat_state.pop(chat_id, None)
 
1597
  bot.chat_state.pop(chat_id, None)
1598
 
1599
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1600
  def test_run_route_cleans_placeholder_on_cancel(monkeypatch):
1601
  # Аудит A4-06: отмена посреди ответа оставляла "…" в чате навсегда.
1602
  placeholder = _FakeSentMessage()
 
1774
  bot.chat_state.pop(123, None)
1775
 
1776
 
1777
+ @pytest.mark.parametrize("with_usage", [True, False])
1778
+ def test_gemini_success_logs_token_usage(caplog, with_usage):
1779
  # Что защищает: единственную точку калибровки лимитов по логам для Gemini
1780
  # (usage_metadata.prompt_token_count/candidates_token_count/total_token_count).
1781
  # Регрессия: тихий дроп usage при рефакторинге ask_gemini — счётчики пропадут из
1782
  # логов, лимиты не на чем калибровать. Старые тесты проверяют только текст/историю.
1783
+ # Без usage от API строка [usage] не пишется, лог не засоряется пустышками.
1784
  import logging
1785
  from types import SimpleNamespace
1786
  chat_id = 999901
1787
 
1788
  def fake_generate_content(*, model, contents, config=None):
1789
  resp = _FakeGeminiResponse(text="Привет!")
1790
+ if with_usage:
1791
+ resp.usage_metadata = SimpleNamespace(prompt_token_count=10, candidates_token_count=20, total_token_count=30)
1792
  return resp
1793
 
1794
  fake_client = MagicMock()
 
1800
  answer = asyncio.run(bot.ask_gemini(chat_id, "Привет"))
1801
  assert answer == "Привет!"
1802
  usage_lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("[usage]")]
1803
+ if with_usage:
1804
+ assert len(usage_lines) == 1
1805
+ line = usage_lines[0]
1806
+ assert f"chat={chat_id}" in line and "provider=gemini" in line
1807
+ assert "prompt=10" in line and "completion=20" in line and "total=30" in line
1808
+ else:
1809
+ assert usage_lines == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1810
  finally:
1811
  bot.client = original_client
1812
  bot.chat_state.pop(chat_id, None)