Spaces:
Running
Running
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 filesGrouping 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 +13 -11
- lumen_routes.py +28 -30
- tests/test_bot_routes.py +79 -4
- tests/test_lumen_model_speed.py +4 -2
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 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 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 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 797 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 1018 |
for provider, model_id in route:
|
| 1019 |
-
|
|
|
|
|
|
|
|
|
|
| 1020 |
first_provider = route[0][0]
|
| 1021 |
-
# Порядок провайдеров — по первому появлению в маршруте (для прежних двухпровайдерных маршрутов то же самое: голова + второй).
|
| 1022 |
-
provider_order = list(dict.fromkeys(p for p, _ in route))
|
| 1023 |
|
| 1024 |
# Учёт дневных лимитов пользователя — только дошедшие до модели ответы.
|
| 1025 |
-
|
| 1026 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1072 |
-
ids = list(
|
| 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 |
-
|
| 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
|
| 370 |
-
#
|
| 371 |
-
# толь
|
| 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
|
| 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
|
| 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 |
-
"
|
| 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 |
|