Spaces:
Running
Running
SilverElixir commited on
Commit ·
5fecfef
1
Parent(s): f189b9e
Trim Groq prompt history to token budget to stop HTTP 413s
Browse files- lumen_routes.py +44 -1
- lumen_streaming.py +7 -2
- tests/test_bot_routes.py +58 -0
lumen_routes.py
CHANGED
|
@@ -243,6 +243,7 @@ async def _chat_completion_chain(
|
|
| 243 |
log.warning('[%s] Route time budget exhausted before model %s. Tried: %s', label, model_trial, ", ".join(tried) or "none")
|
| 244 |
raise bot.RouteBudgetExceededError(tried)
|
| 245 |
tried.append(model_trial)
|
|
|
|
| 246 |
messages[0]["content"] = bot.get_system_prompt(model_trial)
|
| 247 |
attempt_start = time.monotonic()
|
| 248 |
try:
|
|
@@ -274,6 +275,7 @@ async def _chat_completion_chain(
|
|
| 274 |
raise
|
| 275 |
except Exception as exc:
|
| 276 |
last_exc = exc
|
|
|
|
| 277 |
if stop_on is not None and stop_on(exc):
|
| 278 |
log.warning(
|
| 279 |
'[%s] Model %s failed with an account-wide limit — stopping the chain, the rest would fail the same way.',
|
|
@@ -344,8 +346,42 @@ async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[st
|
|
| 344 |
bot._record_quota_usage("openrouter", model_trial)
|
| 345 |
return answer
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
|
| 348 |
-
"""Обычный текстовый запрос через прямой Groq — по образцу ask_openrouter_text: одна попытка на модель, скраб утечек, расход в квоту "groq". Сообщения строит общий _build_openrouter_turn_messages (тот же OpenAI-формат)."""
|
| 349 |
import bot
|
| 350 |
from lumen_router_config import _GROQ_LIGHT_ORDER
|
| 351 |
state = bot.get_state(chat_id)
|
|
@@ -355,6 +391,7 @@ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *,
|
|
| 355 |
trial_models = list(dict.fromkeys(model_chain)) or [_GROQ_LIGHT_ORDER[0]]
|
| 356 |
primary_model_id = trial_models[0]
|
| 357 |
messages = bot._build_openrouter_turn_messages(chat_id, user_text, primary_model_id)
|
|
|
|
| 358 |
|
| 359 |
async def _request(payload: dict, dl: float | None):
|
| 360 |
return await bot._groq_request("chat/completions", "POST", json_body=payload, deadline=dl)
|
|
@@ -788,6 +825,7 @@ async def ask_gemini(
|
|
| 788 |
log.warning('[gemini] Route time budget exhausted. Tried: %s', ", ".join(sorted(tried_models)) or "none")
|
| 789 |
raise bot.RouteBudgetExceededError(sorted(tried_models))
|
| 790 |
tried_models.add(curr_model_id)
|
|
|
|
| 791 |
call_contents, gconfig = bot._build_gemini_call_config(curr_model_id, contents)
|
| 792 |
|
| 793 |
attempt_start = time.monotonic()
|
|
@@ -810,9 +848,11 @@ async def ask_gemini(
|
|
| 810 |
raise
|
| 811 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 812 |
if next_model:
|
|
|
|
| 813 |
log.warning("[gemini] Model %s timed out (%.0fs). Switching to %s", curr_model_id, bot.ROUTE_MODEL_TIMEOUT_SEC, next_model)
|
| 814 |
curr_model_id = next_model
|
| 815 |
continue
|
|
|
|
| 816 |
log.warning("[gemini] Model %s timed out and no fallback models remain in route.", curr_model_id)
|
| 817 |
raise
|
| 818 |
except Exception as exc:
|
|
@@ -834,6 +874,7 @@ async def ask_gemini(
|
|
| 834 |
bot._mark_rate_limited("gemini", curr_model_id)
|
| 835 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 836 |
if next_model:
|
|
|
|
| 837 |
log.warning("[gemini] Model %s quota exhausted (429). Switching to %s", curr_model_id, next_model)
|
| 838 |
curr_model_id = next_model
|
| 839 |
continue
|
|
@@ -848,10 +889,12 @@ async def ask_gemini(
|
|
| 848 |
# Остальные исходы — одна попытка и сразу следующая модель, без ретраев.
|
| 849 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 850 |
if next_model:
|
|
|
|
| 851 |
reason = f"{kind}/{status_code}" if status_code else f"{kind}/{exc_class}"
|
| 852 |
log.warning("[gemini] Model %s failed (%s). Switching to %s", curr_model_id, reason, next_model)
|
| 853 |
curr_model_id = next_model
|
| 854 |
continue
|
|
|
|
| 855 |
log.warning("[gemini] Model %s failed (%s) and no fallback models remain in route.", curr_model_id, exc_class)
|
| 856 |
raise
|
| 857 |
|
|
|
|
| 243 |
log.warning('[%s] Route time budget exhausted before model %s. Tried: %s', label, model_trial, ", ".join(tried) or "none")
|
| 244 |
raise bot.RouteBudgetExceededError(tried)
|
| 245 |
tried.append(model_trial)
|
| 246 |
+
bot._record_stats_event("model_attempts")
|
| 247 |
messages[0]["content"] = bot.get_system_prompt(model_trial)
|
| 248 |
attempt_start = time.monotonic()
|
| 249 |
try:
|
|
|
|
| 275 |
raise
|
| 276 |
except Exception as exc:
|
| 277 |
last_exc = exc
|
| 278 |
+
bot._record_stats_event("model_failures")
|
| 279 |
if stop_on is not None and stop_on(exc):
|
| 280 |
log.warning(
|
| 281 |
'[%s] Model %s failed with an account-wide limit — stopping the chain, the rest would fail the same way.',
|
|
|
|
| 346 |
bot._record_quota_usage("openrouter", model_trial)
|
| 347 |
return answer
|
| 348 |
|
| 349 |
+
# Groq free отдаёт HTTP 413, когда запрос целиком превышает ~7K токенов
|
| 350 |
+
# (прод 05–07.10.2026: история 9–27K токенов — каждая попытка в длинном чате
|
| 351 |
+
# падала, Groq там мёртв принципиально). Системный промпт (~4.5K токенов) уже
|
| 352 |
+
# съедает больше половины, поэтому историю под Groq режем отдельно — остальным
|
| 353 |
+
# провайдерам она по-прежнему едет целиком. Оценка грубая: ASCII ~4 символа на
|
| 354 |
+
# токен, кириллица ~2; бюджет 6K с запасом под погрешность оценки.
|
| 355 |
+
_GROQ_PROMPT_TOKEN_BUDGET = 6000
|
| 356 |
+
|
| 357 |
+
def _estimate_tokens(text: str) -> int:
|
| 358 |
+
"""Грубая оценка токенов без токенизатора: хватает, чтобы не упереться в лимит."""
|
| 359 |
+
if not isinstance(text, str) or not text:
|
| 360 |
+
return 0
|
| 361 |
+
ascii_chars = sum(1 for c in text if ord(c) < 128)
|
| 362 |
+
return ascii_chars // 4 + (len(text) - ascii_chars) // 2
|
| 363 |
+
|
| 364 |
+
def _trim_messages_for_groq(messages: list[dict]) -> list[dict]:
|
| 365 |
+
"""История под лимит Groq: системный промпт и текущий вопрос неприкосновенны,
|
| 366 |
+
из середины выкидываем самые старые, пока влезаем в бюджет. Хранимую историю
|
| 367 |
+
не трогаем — режется только копия под этот запрос."""
|
| 368 |
+
if not messages:
|
| 369 |
+
return messages
|
| 370 |
+
sizes = [_estimate_tokens(m.get("content") if isinstance(m.get("content"), str) else str(m.get("content") or "")) for m in messages]
|
| 371 |
+
if sum(sizes) <= _GROQ_PROMPT_TOKEN_BUDGET:
|
| 372 |
+
return messages
|
| 373 |
+
head, middle, tail = messages[0], messages[1:-1], messages[-1]
|
| 374 |
+
budget_left = _GROQ_PROMPT_TOKEN_BUDGET - sizes[0] - (sizes[-1] if len(messages) > 1 else 0)
|
| 375 |
+
kept: list[dict] = []
|
| 376 |
+
for m, s in zip(reversed(middle), reversed(sizes[1:-1])):
|
| 377 |
+
if s <= budget_left:
|
| 378 |
+
kept.append(m)
|
| 379 |
+
budget_left -= s
|
| 380 |
+
kept.reverse()
|
| 381 |
+
return [head] + kept + ([tail] if len(messages) > 1 else [])
|
| 382 |
+
|
| 383 |
async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
|
| 384 |
+
"""Обычный текстовый запрос через прямой Groq — по образцу ask_openrouter_text: одна попытка на модель, скраб утечек, расход в квоту "groq". Сообщения строит общий _build_openrouter_turn_messages (тот же OpenAI-формат). Историю под запрос режет _trim_messages_for_groq (иначе см. его комментарий)."""
|
| 385 |
import bot
|
| 386 |
from lumen_router_config import _GROQ_LIGHT_ORDER
|
| 387 |
state = bot.get_state(chat_id)
|
|
|
|
| 391 |
trial_models = list(dict.fromkeys(model_chain)) or [_GROQ_LIGHT_ORDER[0]]
|
| 392 |
primary_model_id = trial_models[0]
|
| 393 |
messages = bot._build_openrouter_turn_messages(chat_id, user_text, primary_model_id)
|
| 394 |
+
messages = _trim_messages_for_groq(messages)
|
| 395 |
|
| 396 |
async def _request(payload: dict, dl: float | None):
|
| 397 |
return await bot._groq_request("chat/completions", "POST", json_body=payload, deadline=dl)
|
|
|
|
| 825 |
log.warning('[gemini] Route time budget exhausted. Tried: %s', ", ".join(sorted(tried_models)) or "none")
|
| 826 |
raise bot.RouteBudgetExceededError(sorted(tried_models))
|
| 827 |
tried_models.add(curr_model_id)
|
| 828 |
+
bot._record_stats_event("model_attempts")
|
| 829 |
call_contents, gconfig = bot._build_gemini_call_config(curr_model_id, contents)
|
| 830 |
|
| 831 |
attempt_start = time.monotonic()
|
|
|
|
| 848 |
raise
|
| 849 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 850 |
if next_model:
|
| 851 |
+
bot._record_stats_event("model_failures")
|
| 852 |
log.warning("[gemini] Model %s timed out (%.0fs). Switching to %s", curr_model_id, bot.ROUTE_MODEL_TIMEOUT_SEC, next_model)
|
| 853 |
curr_model_id = next_model
|
| 854 |
continue
|
| 855 |
+
bot._record_stats_event("model_failures")
|
| 856 |
log.warning("[gemini] Model %s timed out and no fallback models remain in route.", curr_model_id)
|
| 857 |
raise
|
| 858 |
except Exception as exc:
|
|
|
|
| 874 |
bot._mark_rate_limited("gemini", curr_model_id)
|
| 875 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 876 |
if next_model:
|
| 877 |
+
bot._record_stats_event("model_failures")
|
| 878 |
log.warning("[gemini] Model %s quota exhausted (429). Switching to %s", curr_model_id, next_model)
|
| 879 |
curr_model_id = next_model
|
| 880 |
continue
|
|
|
|
| 889 |
# Остальные исходы — одна попытка и сразу следующая модель, без ретраев.
|
| 890 |
next_model = bot._next_fallback_model(tried_models, chain)
|
| 891 |
if next_model:
|
| 892 |
+
bot._record_stats_event("model_failures")
|
| 893 |
reason = f"{kind}/{status_code}" if status_code else f"{kind}/{exc_class}"
|
| 894 |
log.warning("[gemini] Model %s failed (%s). Switching to %s", curr_model_id, reason, next_model)
|
| 895 |
curr_model_id = next_model
|
| 896 |
continue
|
| 897 |
+
bot._record_stats_event("model_failures")
|
| 898 |
log.warning("[gemini] Model %s failed (%s) and no fallback models remain in route.", curr_model_id, exc_class)
|
| 899 |
raise
|
| 900 |
|
lumen_streaming.py
CHANGED
|
@@ -248,6 +248,9 @@ async def _run_streaming_reply(
|
|
| 248 |
if placeholder is None:
|
| 249 |
return None, None
|
| 250 |
sent_messages.append(placeholder)
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
# Дальше цикл читает генератор напрямую — проверки кусков/утечек/троттлинга ниже без изменений.
|
| 253 |
piece_agen = _pieces_with_waiting_feedback(
|
|
@@ -404,6 +407,7 @@ async def _run_streaming_reply(
|
|
| 404 |
pass
|
| 405 |
# Плейсхолдер НЕ удаляем — возвращаем для переиспользования (см. докстринг).
|
| 406 |
log.warning('[stream] Stream %s/%s failed before showing any content, falling back to a regular call: %s', provider, model_id, exc)
|
|
|
|
| 407 |
return None, (sent_messages[-1] if sent_messages else None)
|
| 408 |
log.warning('[stream] Stream %s/%s failed after partially showing the response, finishing as-is: %s', provider, model_id, exc)
|
| 409 |
if _detect_identity_leak(full_text):
|
|
@@ -465,9 +469,10 @@ async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Messa
|
|
| 465 |
return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen, deadline=deadline)
|
| 466 |
|
| 467 |
async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None) -> tuple[str | None, Message | None]:
|
| 468 |
-
"""Обёртка _run_streaming_reply для Groq (SSE через chat/completions)."""
|
| 469 |
import bot
|
| 470 |
-
|
|
|
|
| 471 |
# Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
|
| 472 |
piece_agen = bot._groq_stream_pieces(model_id, messages, deadline=deadline)
|
| 473 |
return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen, deadline=deadline)
|
|
|
|
| 248 |
if placeholder is None:
|
| 249 |
return None, None
|
| 250 |
sent_messages.append(placeholder)
|
| 251 |
+
# Попытка к модели состоялась (запрос уже ушёл в генератор кусков);
|
| 252 |
+
# пустой считают только ранний обрыв до показа — частичный ответ показан.
|
| 253 |
+
bot._record_stats_event("model_attempts")
|
| 254 |
|
| 255 |
# Дальше цикл читает генератор напрямую — проверки кусков/утечек/троттлинга ниже без изменений.
|
| 256 |
piece_agen = _pieces_with_waiting_feedback(
|
|
|
|
| 407 |
pass
|
| 408 |
# Плейсхолдер НЕ удаляем — возвращаем для переиспользования (см. докстринг).
|
| 409 |
log.warning('[stream] Stream %s/%s failed before showing any content, falling back to a regular call: %s', provider, model_id, exc)
|
| 410 |
+
bot._record_stats_event("model_failures")
|
| 411 |
return None, (sent_messages[-1] if sent_messages else None)
|
| 412 |
log.warning('[stream] Stream %s/%s failed after partially showing the response, finishing as-is: %s', provider, model_id, exc)
|
| 413 |
if _detect_identity_leak(full_text):
|
|
|
|
| 469 |
return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen, deadline=deadline)
|
| 470 |
|
| 471 |
async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None) -> tuple[str | None, Message | None]:
|
| 472 |
+
"""Обёртка _run_streaming_reply для Groq (SSE через chat/completions). Историю режем тем же _trim_messages_for_groq, что обычный путь, — иначе стрим-проба падает с 413 ещё до цепочки."""
|
| 473 |
import bot
|
| 474 |
+
from lumen_routes import _trim_messages_for_groq
|
| 475 |
+
messages = _trim_messages_for_groq(bot._build_openrouter_turn_messages(chat_id, user_text, model_id))
|
| 476 |
# Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
|
| 477 |
piece_agen = bot._groq_stream_pieces(model_id, messages, deadline=deadline)
|
| 478 |
return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen, deadline=deadline)
|
tests/test_bot_routes.py
CHANGED
|
@@ -1382,6 +1382,64 @@ def test_ask_groq_text_raises_last_error_when_whole_chain_fails(monkeypatch):
|
|
| 1382 |
asyncio.run(bot.ask_groq_text(chat_id, "привет", model_chain=["qwen/qwen3.8-27b", "openai/gpt-oss-120b"]))
|
| 1383 |
|
| 1384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1385 |
def test_shared_chain_raises_budget_error_without_second_attempt(monkeypatch):
|
| 1386 |
# Общий цикл не должен тратить вторую попытку, если бюджет маршрута истёк.
|
| 1387 |
from collections import deque
|
|
|
|
| 1382 |
asyncio.run(bot.ask_groq_text(chat_id, "привет", model_chain=["qwen/qwen3.8-27b", "openai/gpt-oss-120b"]))
|
| 1383 |
|
| 1384 |
|
| 1385 |
+
def test_ask_groq_text_trims_long_history_to_token_budget(monkeypatch):
|
| 1386 |
+
# Прод 05–07.10.2026: история 9–27K токенов давала HTTP 413 на каждую попытку
|
| 1387 |
+
# Groq. Падает на старом коде, где история ехала целиком.
|
| 1388 |
+
import lumen_routes
|
| 1389 |
+
from collections import deque
|
| 1390 |
+
chat_id = 999706
|
| 1391 |
+
history = []
|
| 1392 |
+
for i in range(60):
|
| 1393 |
+
history.append({"role": "user", "content": "вопрос %d " % i + "x" * 500})
|
| 1394 |
+
history.append({"role": "assistant", "content": "ответ %d " % i + "y" * 500})
|
| 1395 |
+
monkeypatch.setattr(bot, "get_state", lambda cid: {"history": history, "ctx": deque()})
|
| 1396 |
+
seen = {}
|
| 1397 |
+
|
| 1398 |
+
async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
|
| 1399 |
+
# Первый запрос — сам ответ; второй (если будет) — саммаризатор истории
|
| 1400 |
+
# _trim_history, его не трогаем.
|
| 1401 |
+
seen.setdefault("messages", json_body["messages"])
|
| 1402 |
+
return {"choices": [{"message": {"content": "ok"}}]}
|
| 1403 |
+
|
| 1404 |
+
monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
|
| 1405 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1406 |
+
monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model, service=False: None)
|
| 1407 |
+
assert asyncio.run(bot.ask_groq_text(chat_id, "текущий вопрос", model_chain=["qwen/qwen3.8-27b"])) == "ok"
|
| 1408 |
+
msgs = seen["messages"]
|
| 1409 |
+
total = sum(lumen_routes._estimate_tokens(m.get("content") if isinstance(m.get("content"), str) else "") for m in msgs)
|
| 1410 |
+
assert total <= lumen_routes._GROQ_PROMPT_TOKEN_BUDGET
|
| 1411 |
+
# Системный промпт и текущий вопрос на месте, хранимая история не ужата.
|
| 1412 |
+
assert msgs[0]["role"] == "system"
|
| 1413 |
+
assert msgs[-1]["content"].endswith("текущий вопрос")
|
| 1414 |
+
# Хранимая история ужатию не подлежит: текущий вопрос-ответ дописаны как есть.
|
| 1415 |
+
assert history[-2]["content"] == "текущий вопрос"
|
| 1416 |
+
assert history[-1] == {"role": "assistant", "content": "ok"}
|
| 1417 |
+
|
| 1418 |
+
|
| 1419 |
+
def test_model_attempt_counters_track_chain_success_and_failure(monkeypatch):
|
| 1420 |
+
# 08.10.2026: по счётчикам model_attempts/model_failures виден реальный КПД
|
| 1421 |
+
# маршрута (раньше делили ответы на попытки только вручную по логам).
|
| 1422 |
+
from collections import deque
|
| 1423 |
+
chat_id = 999707
|
| 1424 |
+
monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
|
| 1425 |
+
calls = {"n": 0}
|
| 1426 |
+
|
| 1427 |
+
async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
|
| 1428 |
+
calls["n"] += 1
|
| 1429 |
+
if calls["n"] == 1:
|
| 1430 |
+
raise bot.GroqAPIError("boom", status_code=500)
|
| 1431 |
+
return {"choices": [{"message": {"content": "ok"}}]}
|
| 1432 |
+
|
| 1433 |
+
monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
|
| 1434 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1435 |
+
monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model, service=False: None)
|
| 1436 |
+
before_a = bot._stats_entry().get("model_attempts", 0)
|
| 1437 |
+
before_f = bot._stats_entry().get("model_failures", 0)
|
| 1438 |
+
assert asyncio.run(bot.ask_groq_text(chat_id, "привет", model_chain=["qwen/qwen3.8-27b", "openai/gpt-oss-120b"])) == "ok"
|
| 1439 |
+
assert bot._stats_entry().get("model_attempts", 0) - before_a == 2
|
| 1440 |
+
assert bot._stats_entry().get("model_failures", 0) - before_f == 1
|
| 1441 |
+
|
| 1442 |
+
|
| 1443 |
def test_shared_chain_raises_budget_error_without_second_attempt(monkeypatch):
|
| 1444 |
# Общий цикл не должен тратить вторую попытку, если бюджет маршрута истёк.
|
| 1445 |
from collections import deque
|