Spaces:
Running
Running
SilverElixir commited on
Commit ·
c836aa8
1
Parent(s): 077fbac
Merge near-duplicate router tests into parametrized cases
Browse files- 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def
|
| 32 |
-
assert bot._classify_model_error(
|
| 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 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 215 |
# РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
|
| 216 |
-
#
|
| 217 |
-
#
|
| 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 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
| 1145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1146 |
def request(self, *args, **kwargs):
|
| 1147 |
-
raise RuntimeError("connection failed, headers
|
| 1148 |
|
| 1149 |
async def fake_get_http_session():
|
| 1150 |
-
return
|
| 1151 |
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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":
|
|
|
|
|
|
|
| 1614 |
|
| 1615 |
async def fake_summarize(text):
|
| 1616 |
return "итог: погода и коты"
|
| 1617 |
|
| 1618 |
-
monkeypatch.setattr(bot,
|
| 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
|
| 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":
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 1909 |
-
|
| 1910 |
-
|
| 1911 |
-
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 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)
|