SilverElixir commited on
Commit
5fcacff
·
1 Parent(s): 7253515

Run routes as consecutive provider blocks and keep Gemma out of the Gemini daily limit

Browse files

Grouping by provider glued the Gemini fallback chain ahead of Groq and burned the route budget; sorting moved Gemma off the head. Blocked Gemini answers now return empty so the chain moves on.

lumen_model_speed.py CHANGED
@@ -68,16 +68,18 @@ def first_chunk_limit_sec(key: str, floor_sec: float) -> float:
68
 
69
 
70
  def reorder_route(route: list[tuple[str, str]]) -> list[tuple[str, str]]:
71
- """Переупорядочить маршрут по задержкам, не ломая архитектуру: блоки провайдеров на месте, внутри блока — по ожидаемому времени. Сортировка стабильная, новички сохраняют кураторский порядок."""
72
- blocks: list[list[tuple[str, str]]] = []
73
- block_index: dict[str, int] = {}
74
- for item in route:
75
- provider = item[0]
76
- if provider not in block_index:
77
- block_index[provider] = len(blocks)
78
- blocks.append([])
79
- blocks[block_index[provider]].append(item)
80
  ordered: list[tuple[str, str]] = []
81
- for block in blocks:
82
- ordered.extend(sorted(block, key=lambda item: expected_total_sec(speed_key(item[0], item[1]))))
 
 
 
 
 
 
83
  return ordered
 
68
 
69
 
70
  def reorder_route(route: list[tuple[str, str]]) -> list[tuple[str, str]]:
71
+ """Переставить модели по задержкам внутри подряд идущего блока одного провайдера.
72
+
73
+ Склейка блоков по провайдеру уводила резервную Gemini-цепочку вперёд Groq
74
+ (аудит 27.10.2026); порядок блоков — часть защиты квот, поэтому не трогаем его.
75
+ """
 
 
 
 
76
  ordered: list[tuple[str, str]] = []
77
+ run: list[tuple[str, str]] = []
78
+ for item in route:
79
+ if run and item[0] != run[-1][0]:
80
+ ordered.extend(sorted(run, key=lambda i: expected_total_sec(speed_key(i[0], i[1]))))
81
+ run = []
82
+ run.append(item)
83
+ if run:
84
+ ordered.extend(sorted(run, key=lambda i: expected_total_sec(speed_key(i[0], i[1]))))
85
  return ordered
lumen_routes.py CHANGED
@@ -30,6 +30,7 @@ from lumen_model_speed import (
30
  )
31
  from lumen_router_config import (
32
  GEMINI_MODELS,
 
33
  _OR_LIGHT_ORDER,
34
  _OR_HEAVY_ORDER,
35
  _OR_VISION_ORDER,
@@ -752,7 +753,6 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
752
  except Exception:
753
  tool_calls.append("response")
754
  if not ans and (tool_calls or reasons):
755
- retry_failed = False
756
  if tool_calls or any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
757
  # Ответ без текста: битый вызов или голый tool_call — пользователю
758
  # JSON инструментов не показываем (прод 07.10.2026), повторяем без них.
@@ -778,24 +778,19 @@ async def _extract_gemini_answer_text(resp: Any, *, model_id: str, call_contents
778
  log.warning("[gemini] Model %s had MALFORMED_FUNCTION_CALL, retried without tools successfully.", model_id)
779
  elif retry_resp is not None:
780
  # Повтор прошёл, но вернул пусто без исчерпания бюджета:
781
- # это повод на следующую модель, а не заглушка успеха.
782
- retry_failed = True
783
  log.warning("[gemini] Retry after MALFORMED_FUNCTION_CALL returned empty text, trying the next model.")
784
  elif deadline is not None and deadline - time.monotonic() <= 0:
785
- # Повтор не успел уложиться в бюджет маршрута. Возвращаем ПУСТОЙ
786
- # ответ, чтобы ask_gemini ушёл на следующую модель, а не подсунул
787
- # пользователю "[Ответ заблокирован...]" (враждебное ревью 27.09.2026):
788
- # раньше повтор выполнялся почти всегда, и эта ветка не всплывала.
789
- retry_failed = True
790
  log.warning("[gemini] Retry after MALFORMED_FUNCTION_CALL skipped: route budget is exhausted, trying the next model.")
791
  except Exception as retry_exc:
792
  log.warning("[gemini] Retry without tools after MALFORMED_FUNCTION_CALL also failed: %s", retry_exc)
793
- # Повтор упал — тоже отдаём пусто: маршрут должен уйти на следующую
794
- # модель, а не показывать заглушку блокировки как готовый ответ
795
- # (иначе цепочка останавливалась на первой же битой модели).
796
- retry_failed = True
797
- if not ans and not retry_failed and reasons:
798
- ans = f"[Ответ заблокирован или пуст. Причина: {', '.join(reasons)}]"
799
  # Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
800
  return ans.strip()
801
 
@@ -1014,16 +1009,23 @@ async def _run_route(
1014
  # Снаружи дедлайн приходит с транскрибацией: она уже потратила часть бюджета.
1015
  deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
1016
 
1017
- groups: dict[str, list[str]] = {"gemini": [], "openrouter": [], "groq": []}
 
 
 
1018
  for provider, model_id in route:
1019
- groups.setdefault(provider, []).append(model_id)
 
 
 
1020
  first_provider = route[0][0]
1021
- # Порядок провайдеров — по первому появлению в маршруте (для прежних двухпровайдерных маршрутов то же самое: голова + второй).
1022
- provider_order = list(dict.fromkeys(p for p, _ in route))
1023
 
1024
  # Учёт дневных лимитов пользователя — только дошедшие до модели ответы.
1025
- def _count_user_answer(provider_name: str) -> None:
1026
- bot._record_user_daily(bot._user_key_for_message(message), gemini=(provider_name == "gemini"))
 
 
 
1027
  bot._record_stats_event("answers_sent")
1028
 
1029
  # Кто фактически ответил (прод 12.10.2026): для пост-проверки живого поиска.
@@ -1041,7 +1043,7 @@ async def _run_route(
1041
  streamed, placeholder = await bot._try_gemini_streaming(chat_id, ai_prompt, message, head_model, deadline=deadline)
1042
  if streamed is not None:
1043
  log.info('[router] chat=%s response received via streaming (gemini:%s)', chat_id, head_model)
1044
- _count_user_answer("gemini")
1045
  _note_answered("gemini", head_model)
1046
  return streamed, True
1047
  tried_stream_model, tried_stream_provider = head_model, "gemini"
@@ -1068,8 +1070,8 @@ async def _run_route(
1068
  is_video_or_audio = bool(media) and any(not (m[1] or "").startswith("image/") for m in media)
1069
  last_exc: Exception | None = None
1070
  media_note_added = False
1071
- for provider in provider_order:
1072
- ids = list(groups.get(provider) or [])
1073
  if provider == tried_stream_provider and tried_stream_model in ids:
1074
  ids = [m for m in ids if m != tried_stream_model]
1075
  if not ids:
@@ -1101,9 +1103,9 @@ async def _run_route(
1101
  await bot._delete_message_quietly(reusable_placeholder)
1102
  reusable_placeholder = None
1103
  if reused:
1104
- _count_user_answer(provider)
1105
  return ans, True
1106
- _count_user_answer(provider)
1107
  return ans, False
1108
  except asyncio.CancelledError:
1109
  # Отмена посреди ответа: плейсхолдер чистим, отмену пробрасываем —
@@ -1124,11 +1126,7 @@ async def _run_route(
1124
  media_note_added = True
1125
  log.warning('[router] Provider %s failed completely (%s), trying the next one on the route, if any.', provider, exc)
1126
  # Суточный счётчик /stats: дальше по маршруту есть кому ответить.
1127
- try:
1128
- _left = provider_order.index(provider) + 1
1129
- except ValueError:
1130
- _left = len(provider_order)
1131
- if any(groups.get(_p) for _p in provider_order[_left:]):
1132
  bot._record_stats_event("fallbacks")
1133
 
1134
  if reusable_placeholder is not None:
 
30
  )
31
  from lumen_router_config import (
32
  GEMINI_MODELS,
33
+ _GEMMA_TEXT_CHAIN,
34
  _OR_LIGHT_ORDER,
35
  _OR_HEAVY_ORDER,
36
  _OR_VISION_ORDER,
 
753
  except Exception:
754
  tool_calls.append("response")
755
  if not ans and (tool_calls or reasons):
 
756
  if tool_calls or any("MALFORMED_FUNCTION_CALL" in r for r in reasons):
757
  # Ответ без текста: битый вызов или голый tool_call — пользователю
758
  # JSON инструментов не показываем (прод 07.10.2026), повторяем без них.
 
778
  log.warning("[gemini] Model %s had MALFORMED_FUNCTION_CALL, retried without tools successfully.", model_id)
779
  elif retry_resp is not None:
780
  # Повтор прошёл, но вернул пусто без исчерпания бюджета:
781
+ # это повод на следующую модель.
 
782
  log.warning("[gemini] Retry after MALFORMED_FUNCTION_CALL returned empty text, trying the next model.")
783
  elif deadline is not None and deadline - time.monotonic() <= 0:
784
+ # Повтор не успел уложиться в бюджет маршрута: отдаём пусто,
785
+ # ask_gemini перейдёт к следующей модели.
 
 
 
786
  log.warning("[gemini] Retry after MALFORMED_FUNCTION_CALL skipped: route budget is exhausted, trying the next model.")
787
  except Exception as retry_exc:
788
  log.warning("[gemini] Retry without tools after MALFORMED_FUNCTION_CALL also failed: %s", retry_exc)
789
+ # Повтор упал — отдаём пусто: маршрут уйдёт к следующей модели,
790
+ # а не остановится на первой же битой.
791
+ # Блокировка (SAFETY/RECITATION) — не ответ: пустая строка двигает цепочку
792
+ # дальше, а локализованный отказ пользователь увидит, только если не ответил
793
+ # никто (аудит 27.10.2026: русская заглушка останавливала маршрут).
 
794
  # Пустая строка без блокировки — не "Empty response": ask_gemini пробует следующую модель.
795
  return ans.strip()
796
 
 
1009
  # Снаружи дедлайн приходит с транскрибацией: она уже потратила часть бюджета.
1010
  deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
1011
 
1012
+ # Блоки идут ПОДРЯД, как собраны: Gemma → Groq → OpenRouter → Gemini-резерв.
1013
+ # Склейка по провайдеру ставила 11 Gemini-моделей впереди Groq и съедала
1014
+ # 40-секундный бюджет до первого независимого провайдера (аудит 27.10.2026).
1015
+ runs: list[tuple[str, list[str]]] = []
1016
  for provider, model_id in route:
1017
+ if runs and runs[-1][0] == provider:
1018
+ runs[-1][1].append(model_id)
1019
+ else:
1020
+ runs.append((provider, [model_id]))
1021
  first_provider = route[0][0]
 
 
1022
 
1023
  # Учёт дневных лимитов пользователя — только дошедшие до модели ответы.
1024
+ # Gemma не в счёт: у неё отдельная большая квота, иначе 5 обычных сообщений
1025
+ # запирали голос/видео на день (аудит 27.10.2026).
1026
+ def _count_user_answer(provider_name: str, model_id: str | None = None) -> None:
1027
+ counts_gemini = provider_name == "gemini" and (model_id or "") not in _GEMMA_TEXT_CHAIN
1028
+ bot._record_user_daily(bot._user_key_for_message(message), gemini=counts_gemini)
1029
  bot._record_stats_event("answers_sent")
1030
 
1031
  # Кто фактически ответил (прод 12.10.2026): для пост-проверки живого поиска.
 
1043
  streamed, placeholder = await bot._try_gemini_streaming(chat_id, ai_prompt, message, head_model, deadline=deadline)
1044
  if streamed is not None:
1045
  log.info('[router] chat=%s response received via streaming (gemini:%s)', chat_id, head_model)
1046
+ _count_user_answer("gemini", head_model)
1047
  _note_answered("gemini", head_model)
1048
  return streamed, True
1049
  tried_stream_model, tried_stream_provider = head_model, "gemini"
 
1070
  is_video_or_audio = bool(media) and any(not (m[1] or "").startswith("image/") for m in media)
1071
  last_exc: Exception | None = None
1072
  media_note_added = False
1073
+ for run_index, (provider, run_ids) in enumerate(runs):
1074
+ ids = list(run_ids)
1075
  if provider == tried_stream_provider and tried_stream_model in ids:
1076
  ids = [m for m in ids if m != tried_stream_model]
1077
  if not ids:
 
1103
  await bot._delete_message_quietly(reusable_placeholder)
1104
  reusable_placeholder = None
1105
  if reused:
1106
+ _count_user_answer(provider, (provenance or {}).get("model"))
1107
  return ans, True
1108
+ _count_user_answer(provider, (provenance or {}).get("model"))
1109
  return ans, False
1110
  except asyncio.CancelledError:
1111
  # Отмена посреди ответа: плейсхолдер чистим, отмену пробрасываем —
 
1126
  media_note_added = True
1127
  log.warning('[router] Provider %s failed completely (%s), trying the next one on the route, if any.', provider, exc)
1128
  # Суточный счётчик /stats: дальше по маршруту есть кому ответить.
1129
+ if any(ids for _, ids in runs[run_index + 1:]):
 
 
 
 
1130
  bot._record_stats_event("fallbacks")
1131
 
1132
  if reusable_placeholder is not None:
tests/test_bot_routes.py CHANGED
@@ -366,14 +366,14 @@ def test_extract_gemini_answer_skips_retry_when_route_budget_spent():
366
  bot.client = original_client
367
 
368
 
369
- def test_extract_gemini_answer_reports_non_malformed_block_without_retry_state(): # Пустой ответ с причиной без MALFORMED_FUNCTION_CALL не должен падать из-за
370
- # неинициализированного флага пропуска ретрая. Раньше правка оставляла переменную
371
- # только внутри MALFORMED-ветки и такой ответ давал UnboundLocalError.
372
  resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="SAFETY")])
373
  ans = asyncio.run(bot._extract_gemini_answer_text(
374
  resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
375
  ))
376
- assert "SAFETY" in ans
377
 
378
 
379
  def test_extract_gemini_answer_retries_without_tools_on_tool_only_response():
@@ -1874,6 +1874,81 @@ def test_run_route_failure_records_no_user_daily():
1874
  bot.chat_state.pop(chat_id, None)
1875
 
1876
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1877
  def test_handled_message_grows_provider_quota_user_daily_and_stats(rate_guard_setup, monkeypatch):
1878
  # Что защищает: воронку учёта после обработанного сообщения (квота
1879
  # провайдера + user_daily + суточные счётчики /stats одним проходом).
 
366
  bot.client = original_client
367
 
368
 
369
+ def test_extract_gemini_answer_returns_empty_for_blocked_response():
370
+ # Блокировка (SAFETY) — не ответ: пустая строка двигает цепочку дальше,
371
+ # а не подсовывает пользователю русскую техническую заглушку (аудит 27.10.2026).
372
  resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="SAFETY")])
373
  ans = asyncio.run(bot._extract_gemini_answer_text(
374
  resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
375
  ))
376
+ assert ans == ""
377
 
378
 
379
  def test_extract_gemini_answer_retries_without_tools_on_tool_only_response():
 
1874
  bot.chat_state.pop(chat_id, None)
1875
 
1876
 
1877
+ def test_run_route_keeps_route_block_order(monkeypatch):
1878
+ # Что защищает: маршрут исполняется блоками в том порядке, как собран
1879
+ # (Gemma → Groq → OpenRouter → Gemini-резерв). Регрессия: склейка блоков по
1880
+ # провайдеру отправляла Gemma одним вызовом со всей Gemini-цепочкой и съедала
1881
+ # бюджет до первого независимого провайдера (аудит 27.10.2026).
1882
+ calls = []
1883
+
1884
+ async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1885
+ deadline=None, provenance=None):
1886
+ calls.append(("gemini", list(model_chain)))
1887
+ if model_chain == ["gemini-3.8-flash"]:
1888
+ if provenance is not None:
1889
+ provenance.update(provider="gemini", model="gemini-3.8-flash")
1890
+ return "резерв Gemini"
1891
+ raise RuntimeError("gemma недоступна")
1892
+
1893
+ async def failing_groq(cid, prompt, model_chain, deadline=None):
1894
+ calls.append(("groq", list(model_chain)))
1895
+ raise bot.GroqAPIError("overloaded", status_code=503)
1896
+
1897
+ async def failing_or(cid, prompt, model_chain, deadline=None):
1898
+ calls.append(("openrouter", list(model_chain)))
1899
+ raise bot.OpenRouterAPIError("сломано", status_code=500)
1900
+
1901
+ monkeypatch.setattr(bot, "ask_gemini", fake_ask_gemini)
1902
+ monkeypatch.setattr(bot, "ask_groq_text", failing_groq)
1903
+ monkeypatch.setattr(bot, "ask_openrouter_text", failing_or)
1904
+ route = [
1905
+ ("gemini", "gemma-4-26b-a4b-it"),
1906
+ ("groq", "qwen/qwen3.8-27b"),
1907
+ ("openrouter", "liquid/lfm-2.5-2.6b:free"),
1908
+ ("gemini", "gemini-3.8-flash"),
1909
+ ]
1910
+ ans, sent = asyncio.run(bot._run_route(999310, "привет", route, message=None, allow_stream=False))
1911
+ assert ans == "резерв Gemini" and sent is False
1912
+ assert calls == [
1913
+ ("gemini", ["gemma-4-26b-a4b-it"]),
1914
+ ("groq", ["qwen/qwen3.8-27b"]),
1915
+ ("openrouter", ["liquid/lfm-2.5-2.6b:free"]),
1916
+ ("gemini", ["gemini-3.8-flash"]),
1917
+ ]
1918
+
1919
+
1920
+ def test_run_route_gemma_answer_does_not_spend_gemini_user_limit():
1921
+ # Что защищает: дневной лимит Gemini у пользователя. Регрессия: ответ Gemma
1922
+ # считался как Gemini, и несколько обычных сообщений запирали голос/видео
1923
+ # на сутки (аудит 27.10.2026). Модель берётся из provenance, как в проде.
1924
+ from types import SimpleNamespace
1925
+ chat_id = 999311
1926
+ incoming = _FakeIncomingMessage(chat_id)
1927
+ incoming.from_user = SimpleNamespace(id=777013)
1928
+
1929
+ async def fake_ask_gemini(cid, prompt, media=None, youtube_url=None, model_chain=None,
1930
+ deadline=None, provenance=None):
1931
+ if provenance is not None:
1932
+ provenance.update(provider="gemini", model="gemma-4-26b-a4b-it")
1933
+ return "Ответ Gemma"
1934
+
1935
+ original = bot.ask_gemini
1936
+ bot.ask_gemini = fake_ask_gemini
1937
+ try:
1938
+ ans, _ = asyncio.run(bot._run_route(
1939
+ chat_id, "привет", [("gemini", "gemma-4-26b-a4b-it")], incoming,
1940
+ allow_stream=False, provenance={},
1941
+ ))
1942
+ assert ans == "Ответ Gemma"
1943
+ entry = bot.GLOBAL_QUOTA.get("user_daily", {}).get("777013")
1944
+ assert entry is not None
1945
+ assert entry["total"] == 1 and entry["gemini"] == 0
1946
+ finally:
1947
+ bot.ask_gemini = original
1948
+ bot.GLOBAL_QUOTA.get("user_daily", {}).pop("777013", None)
1949
+ bot.chat_state.pop(chat_id, None)
1950
+
1951
+
1952
  def test_handled_message_grows_provider_quota_user_daily_and_stats(rate_guard_setup, monkeypatch):
1953
  # Что защищает: воронку учёта после обработанного сообщения (квота
1954
  # провайдера + user_daily + суточные счётчики /stats одним проходом).
tests/test_lumen_model_speed.py CHANGED
@@ -71,7 +71,7 @@ def test_first_chunk_limit_respects_floor_and_adapts():
71
  assert lumen_model_speed.first_chunk_limit_sec(slow, 12.0) == 75.0
72
 
73
 
74
- def test_reorder_route_sorts_within_provider_blocks_only():
75
  _reset()
76
  slow = ("openrouter", "slow:free")
77
  fast = ("openrouter", "fast:free")
@@ -79,8 +79,10 @@ def test_reorder_route_sorts_within_provider_blocks_only():
79
  lumen_model_speed.record_response(lumen_model_speed.speed_key(*fast), total_sec=5.0)
80
  route = [slow, ("gemini", "gemini-3.8-flash"), fast, ("openrouter", "openrouter/free")]
81
  reordered = lumen_model_speed.reorder_route(route)
 
 
82
  assert [m for _, m in reordered] == [
83
- "fast:free", "openrouter/free", "slow:free", "gemini-3.8-flash",
84
  ]
85
 
86
 
 
71
  assert lumen_model_speed.first_chunk_limit_sec(slow, 12.0) == 75.0
72
 
73
 
74
+ def test_reorder_route_sorts_within_consecutive_runs_only():
75
  _reset()
76
  slow = ("openrouter", "slow:free")
77
  fast = ("openrouter", "fast:free")
 
79
  lumen_model_speed.record_response(lumen_model_speed.speed_key(*fast), total_sec=5.0)
80
  route = [slow, ("gemini", "gemini-3.8-flash"), fast, ("openrouter", "openrouter/free")]
81
  reordered = lumen_model_speed.reorder_route(route)
82
+ # Блоки не склеиваются по провайдеру: медленная голова не переезжает через
83
+ # Gemini, порядок блоков остаётся кураторским (аудит 27.10.2026).
84
  assert [m for _, m in reordered] == [
85
+ "slow:free", "gemini-3.8-flash", "fast:free", "openrouter/free",
86
  ]
87
 
88