SilverElixir commited on
Commit
5fecfef
·
1 Parent(s): f189b9e

Trim Groq prompt history to token budget to stop HTTP 413s

Browse files
Files changed (3) hide show
  1. lumen_routes.py +44 -1
  2. lumen_streaming.py +7 -2
  3. 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
- messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
 
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