Spaces:
Running
Running
SilverElixir commited on
Commit ·
453b05f
1
Parent(s): bae36ef
Fix ttf EMA pollution, quota log, explicit url_context, dynamic freshness years, transcribe cap (A2-4, A2-10..A2-13)
Browse files- lumen_model_speed.py +7 -4
- lumen_router_config.py +13 -8
- lumen_routes.py +4 -1
- tests/test_bot_routes.py +37 -0
- tests/test_lumen_model_speed.py +7 -2
- tests/test_lumen_router_config.py +28 -0
lumen_model_speed.py
CHANGED
|
@@ -42,14 +42,17 @@ def expected_total_sec(key: str) -> float:
|
|
| 42 |
|
| 43 |
|
| 44 |
def record_response(key: str, *, total_sec: float, ttf_sec: float | None = None) -> None:
|
| 45 |
-
"""Учесть один УСПЕШНЫЙ ответ. Без ttf_sec
|
|
|
|
|
|
|
| 46 |
if total_sec <= 0:
|
| 47 |
return
|
|
|
|
| 48 |
if ttf_sec is None or ttf_sec <= 0:
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
obs_total = max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total_sec))
|
| 52 |
-
prev = _latency_ema.get(key)
|
| 53 |
if prev is None:
|
| 54 |
_latency_ema[key] = (obs_ttf, obs_total)
|
| 55 |
else:
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def record_response(key: str, *, total_sec: float, ttf_sec: float | None = None) -> None:
|
| 45 |
+
"""Учесть один УСПЕШНЫЙ ответ. Без ttf_sec (нестриминговый ответ первого куска
|
| 46 |
+
не видел) ttf-EMA не трогаем: подстановка total завышала first_chunk_limit,
|
| 47 |
+
и зависшая голова держалась дольше. Неположительные замеры игнорируются."""
|
| 48 |
if total_sec <= 0:
|
| 49 |
return
|
| 50 |
+
prev = _latency_ema.get(key)
|
| 51 |
if ttf_sec is None or ttf_sec <= 0:
|
| 52 |
+
obs_ttf = prev[0] if prev is not None else _DEFAULT_TTF_SEC
|
| 53 |
+
else:
|
| 54 |
+
obs_ttf = max(_MIN_TTF_SEC, min(_MAX_TTF_SEC, ttf_sec))
|
| 55 |
obs_total = max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total_sec))
|
|
|
|
| 56 |
if prev is None:
|
| 57 |
_latency_ema[key] = (obs_ttf, obs_total)
|
| 58 |
else:
|
lumen_router_config.py
CHANGED
|
@@ -53,30 +53,30 @@ GEMINI_MODELS: dict[str, dict[str, Any]] = {
|
|
| 53 |
# Быстрая и экономичная (21.07.2026, до 350 токенов/сек).
|
| 54 |
"gemini-3.5-flash-lite": {
|
| 55 |
"stream": True,
|
| 56 |
-
"search_grounding": False, "map_grounding": True,
|
| 57 |
},
|
| 58 |
# Резерв после 3.5 Flash-Lite.
|
| 59 |
"gemini-3.1-flash-lite": {
|
| 60 |
"stream": True,
|
| 61 |
-
"search_grounding": False, "map_grounding": True,
|
| 62 |
},
|
| 63 |
# Баланс скорости и качества. Единственное поколение с квотой search grounding (21/1500).
|
| 64 |
"gemini-2.5-flash": {
|
| 65 |
"stream": True,
|
| 66 |
-
"search_grounding": True, "map_grounding": True,
|
| 67 |
},
|
| 68 |
# Экономичная: скорость важнее глубины рассуждений.
|
| 69 |
"gemini-2.5-flash-lite": {
|
| 70 |
"stream": True,
|
| 71 |
-
"search_grounding": True, "map_grounding": True,
|
| 72 |
},
|
| 73 |
"gemma-4-31b-it": {
|
| 74 |
-
"no_system": True, "no_search": True, "stream": True,
|
| 75 |
},
|
| 76 |
"gemma-4-26b-a4b-it": {
|
| 77 |
"no_system": True,
|
| 78 |
# 24.07.2026: без no_search Gemma получала бы инструменты, которых не поддерживает.
|
| 79 |
-
"no_search": True, "stream": True,
|
| 80 |
},
|
| 81 |
}
|
| 82 |
DEFAULT_GEMINI_MODEL = "gemini-3.8-flash"
|
|
@@ -259,7 +259,10 @@ def _is_quota_exhausted(provider: str, model_id: str) -> bool:
|
|
| 259 |
return True
|
| 260 |
cooldown_until = entry.get("cooldown_until")
|
| 261 |
return bool(cooldown_until and cooldown_until > time.time())
|
| 262 |
-
except Exception:
|
|
|
|
|
|
|
|
|
|
| 263 |
return False
|
| 264 |
|
| 265 |
def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
|
|
@@ -395,6 +398,8 @@ def _looks_like_heavy_query(text: str) -> bool:
|
|
| 395 |
|
| 396 |
# ── Эвристика свежести без LLM.
|
| 397 |
# Ложные срабатывания дёшевы: модель сама решает, вызывать ли поиск.
|
|
|
|
|
|
|
| 398 |
_FRESHNESS_QUERY_RE = re.compile(
|
| 399 |
r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
|
| 400 |
r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
|
|
@@ -406,7 +411,7 @@ _FRESHNESS_QUERY_RE = re.compile(
|
|
| 406 |
r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
|
| 407 |
r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
|
| 408 |
r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
|
| 409 |
-
|
| 410 |
# EN-набор: детект был только русским при 25 языках (внешний аудит). Границы слов обязательны: now ловил know/snow.
|
| 411 |
r"|\bnow\b|\btoday\b|current\w*|latest|recent\w*|\bbreaking\b"
|
| 412 |
r"|\bnews\b|weather|forecast|price\w*|\bcost\w*|how\s+much|exchange|\bscore\w*|schedule"
|
|
|
|
| 53 |
# Быстрая и экономичная (21.07.2026, до 350 токенов/сек).
|
| 54 |
"gemini-3.5-flash-lite": {
|
| 55 |
"stream": True,
|
| 56 |
+
"search_grounding": False, "map_grounding": True, "url_context": True,
|
| 57 |
},
|
| 58 |
# Резерв после 3.5 Flash-Lite.
|
| 59 |
"gemini-3.1-flash-lite": {
|
| 60 |
"stream": True,
|
| 61 |
+
"search_grounding": False, "map_grounding": True, "url_context": True,
|
| 62 |
},
|
| 63 |
# Баланс скорости и качества. Единственное поколение с квотой search grounding (21/1500).
|
| 64 |
"gemini-2.5-flash": {
|
| 65 |
"stream": True,
|
| 66 |
+
"search_grounding": True, "map_grounding": True, "url_context": True,
|
| 67 |
},
|
| 68 |
# Экономичная: скорость важнее глубины рассуждений.
|
| 69 |
"gemini-2.5-flash-lite": {
|
| 70 |
"stream": True,
|
| 71 |
+
"search_grounding": True, "map_grounding": True, "url_context": True,
|
| 72 |
},
|
| 73 |
"gemma-4-31b-it": {
|
| 74 |
+
"no_system": True, "no_search": True, "stream": True, "url_context": True,
|
| 75 |
},
|
| 76 |
"gemma-4-26b-a4b-it": {
|
| 77 |
"no_system": True,
|
| 78 |
# 24.07.2026: без no_search Gemma получала бы инструменты, которых не поддерживает.
|
| 79 |
+
"no_search": True, "stream": True, "url_context": True,
|
| 80 |
},
|
| 81 |
}
|
| 82 |
DEFAULT_GEMINI_MODEL = "gemini-3.8-flash"
|
|
|
|
| 259 |
return True
|
| 260 |
cooldown_until = entry.get("cooldown_until")
|
| 261 |
return bool(cooldown_until and cooldown_until > time.time())
|
| 262 |
+
except Exception as exc:
|
| 263 |
+
# Битое состояние квоты чиним видимой ошибкой в логе, а не молчаливыми
|
| 264 |
+
# лишними попытками: fail-open здесь — пропуск фильтра.
|
| 265 |
+
log.warning("[router] Quota state unreadable, treating %s/%s as available: %s", provider, model_id, exc)
|
| 266 |
return False
|
| 267 |
|
| 268 |
def _skip_exhausted(provider: str, models: list[str]) -> list[str]:
|
|
|
|
| 398 |
|
| 399 |
# ── Эвристика свежести без LLM.
|
| 400 |
# Ложные срабатывания дёшевы: модель сама решает, вызывать ли поиск.
|
| 401 |
+
# Годы свежести — окном от текущей даты, а не зашитым диапазоном (иначе протухает).
|
| 402 |
+
_FRESHNESS_YEAR_ALTS = "|".join(str(date.today().year + i) for i in range(4))
|
| 403 |
_FRESHNESS_QUERY_RE = re.compile(
|
| 404 |
r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
|
| 405 |
r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*|прогноз\w*"
|
|
|
|
| 411 |
r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
|
| 412 |
r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
|
| 413 |
r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
|
| 414 |
+
rf"|\b(?:{_FRESHNESS_YEAR_ALTS})\b"
|
| 415 |
# EN-набор: детект был только русским при 25 языках (внешний аудит). Границы слов обязательны: now ловил know/snow.
|
| 416 |
r"|\bnow\b|\btoday\b|current\w*|latest|recent\w*|\bbreaking\b"
|
| 417 |
r"|\bnews\b|weather|forecast|price\w*|\bcost\w*|how\s+much|exchange|\bscore\w*|schedule"
|
lumen_routes.py
CHANGED
|
@@ -39,6 +39,9 @@ from lumen_security import _scrub_identity_leak
|
|
| 39 |
|
| 40 |
# Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
|
| 41 |
_TRANSCRIPT_MAX_CHARS = 4000
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
log = logging.getLogger("bot")
|
| 44 |
|
|
@@ -351,7 +354,7 @@ async def _transcribe_audio(audio_bytes: bytes, mime: str, chat_id: int, deadlin
|
|
| 351 |
f"{bot.GROQ_BASE_URL}/audio/transcriptions",
|
| 352 |
headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
|
| 353 |
data=form,
|
| 354 |
-
timeout=aiohttp.ClientTimeout(total=_attempt_timeout(bot, deadline), connect=10.0),
|
| 355 |
) as resp:
|
| 356 |
if resp.status >= 400:
|
| 357 |
body = await resp.read()
|
|
|
|
| 39 |
|
| 40 |
# Транскрипт длинного войса режем сверху — иначе маршрут ниже упрётся в лимиты моделей.
|
| 41 |
_TRANSCRIPT_MAX_CHARS = 4000
|
| 42 |
+
# Транскрибация не должна съедать весь бюджет маршрута: войсы короткие (до
|
| 43 |
+
# VOICE_TRANSCRIBE_MAX_BYTES), turbo-модель отвечает секундами — остаток оставляем модели.
|
| 44 |
+
_TRANSCRIBE_TIMEOUT_SEC = 15.0
|
| 45 |
|
| 46 |
log = logging.getLogger("bot")
|
| 47 |
|
|
|
|
| 354 |
f"{bot.GROQ_BASE_URL}/audio/transcriptions",
|
| 355 |
headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
|
| 356 |
data=form,
|
| 357 |
+
timeout=aiohttp.ClientTimeout(total=min(_attempt_timeout(bot, deadline), _TRANSCRIBE_TIMEOUT_SEC), connect=10.0),
|
| 358 |
) as resp:
|
| 359 |
if resp.status >= 400:
|
| 360 |
body = await resp.read()
|
tests/test_bot_routes.py
CHANGED
|
@@ -1485,6 +1485,43 @@ def test_transcribe_audio_uses_remaining_budget_not_full_timeout(monkeypatch):
|
|
| 1485 |
assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 1486 |
|
| 1487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1488 |
def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
|
| 1489 |
# Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
|
| 1490 |
import lumen_chat_state
|
|
|
|
| 1485 |
assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 1486 |
|
| 1487 |
|
| 1488 |
+
def test_transcribe_audio_capped_by_own_limit(monkeypatch):
|
| 1489 |
+
# Аудит A2-12: транскрибация ждала остаток всего бюджета и морила модель —
|
| 1490 |
+
# теперь отдельный меньший лимит поверх остатка.
|
| 1491 |
+
import lumen_routes
|
| 1492 |
+
captured = {}
|
| 1493 |
+
|
| 1494 |
+
class _FakeResp:
|
| 1495 |
+
status = 200
|
| 1496 |
+
|
| 1497 |
+
async def __aenter__(self):
|
| 1498 |
+
return self
|
| 1499 |
+
|
| 1500 |
+
async def __aexit__(self, *args):
|
| 1501 |
+
return False
|
| 1502 |
+
|
| 1503 |
+
async def read(self):
|
| 1504 |
+
return b"{}"
|
| 1505 |
+
|
| 1506 |
+
async def json(self, content_type=None):
|
| 1507 |
+
return {"text": "привет"}
|
| 1508 |
+
|
| 1509 |
+
class _FakeSession:
|
| 1510 |
+
def post(self, *args, **kwargs):
|
| 1511 |
+
captured["timeout"] = kwargs.get("timeout")
|
| 1512 |
+
return _FakeResp()
|
| 1513 |
+
|
| 1514 |
+
async def fake_get_http_session():
|
| 1515 |
+
return _FakeSession()
|
| 1516 |
+
|
| 1517 |
+
monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
|
| 1518 |
+
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
|
| 1519 |
+
text = asyncio.run(bot._transcribe_audio(b"ogg-bytes", "audio/ogg", 123, deadline=time.monotonic() + 1000.0))
|
| 1520 |
+
assert text == "привет"
|
| 1521 |
+
assert captured["timeout"].total <= lumen_routes._TRANSCRIBE_TIMEOUT_SEC
|
| 1522 |
+
assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 1523 |
+
|
| 1524 |
+
|
| 1525 |
def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
|
| 1526 |
# Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
|
| 1527 |
import lumen_chat_state
|
tests/test_lumen_model_speed.py
CHANGED
|
@@ -47,11 +47,16 @@ def test_record_response_clamps_outliers_and_ignores_garbage():
|
|
| 47 |
assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._MAX_TTF_SEC
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
|
|
|
|
|
|
| 51 |
_reset()
|
| 52 |
key = lumen_model_speed.speed_key("openrouter", "m:free")
|
| 53 |
lumen_model_speed.record_response(key, total_sec=30.0)
|
| 54 |
-
assert lumen_model_speed.expected_ttf_sec(key) ==
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def test_first_chunk_limit_respects_floor_and_adapts():
|
|
|
|
| 47 |
assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._MAX_TTF_SEC
|
| 48 |
|
| 49 |
|
| 50 |
+
def test_record_response_without_ttf_keeps_ttf_ema():
|
| 51 |
+
# Аудит A2-4: нестриминговый ответ первого куска не видел — подстановка total
|
| 52 |
+
# в ttf-EMA завышала first_chunk_limit, зависшая голова держалась дольше.
|
| 53 |
_reset()
|
| 54 |
key = lumen_model_speed.speed_key("openrouter", "m:free")
|
| 55 |
lumen_model_speed.record_response(key, total_sec=30.0)
|
| 56 |
+
assert lumen_model_speed.expected_ttf_sec(key) == lumen_model_speed._DEFAULT_TTF_SEC
|
| 57 |
+
lumen_model_speed.record_response(key, total_sec=6.0, ttf_sec=2.0)
|
| 58 |
+
lumen_model_speed.record_response(key, total_sec=80.0)
|
| 59 |
+
assert lumen_model_speed.expected_ttf_sec(key) < 10.0
|
| 60 |
|
| 61 |
|
| 62 |
def test_first_chunk_limit_respects_floor_and_adapts():
|
tests/test_lumen_router_config.py
CHANGED
|
@@ -20,6 +20,7 @@ logger="bot") ниже продолжает работать так же, как
|
|
| 20 |
pytest test_lumen_router_config.py -v
|
| 21 |
"""
|
| 22 |
import lumen_router_config
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
|
|
@@ -695,3 +696,30 @@ def test_successful_usage_clears_exhausted_mark():
|
|
| 695 |
assert [m for _, m in lumen_router_config._or_route(["probe/recover:free"])] == ["probe/recover:free"]
|
| 696 |
finally:
|
| 697 |
bot.GLOBAL_QUOTA["openrouter"].pop("probe/recover:free", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
pytest test_lumen_router_config.py -v
|
| 21 |
"""
|
| 22 |
import lumen_router_config
|
| 23 |
+
from datetime import date
|
| 24 |
|
| 25 |
|
| 26 |
|
|
|
|
| 696 |
assert [m for _, m in lumen_router_config._or_route(["probe/recover:free"])] == ["probe/recover:free"]
|
| 697 |
finally:
|
| 698 |
bot.GLOBAL_QUOTA["openrouter"].pop("probe/recover:free", None)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
def test_all_gemini_models_declare_url_context_explicitly():
|
| 702 |
+
# Аудит A2-11: дефолт True в conf.get давал инструмент молча — теперь ключ
|
| 703 |
+
# прописан явно у каждой модели (поведение то же, намерение видно).
|
| 704 |
+
missing = [m for m, c in lumen_router_config.GEMINI_MODELS.items() if "url_context" not in c]
|
| 705 |
+
assert missing == []
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
def test_freshness_year_window_tracks_current_year():
|
| 709 |
+
# Аудит A2-13: зашитый 202[6-9] протухал в 2030 — окно строится от текущей даты.
|
| 710 |
+
assert str(date.today().year) in lumen_router_config._FRESHNESS_QUERY_RE.pattern
|
| 711 |
+
assert lumen_router_config._looks_like_freshness_query("вспомним 2039 год") is False
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def test_quota_exhausted_logs_broken_state_loudly(monkeypatch, caplog):
|
| 715 |
+
# Аудит A2-10: битое состояние квоты раньше давало молчаливый fail-open.
|
| 716 |
+
import bot
|
| 717 |
+
|
| 718 |
+
class _BrokenQuota(dict):
|
| 719 |
+
def get(self, *args, **kwargs):
|
| 720 |
+
raise RuntimeError("boom")
|
| 721 |
+
|
| 722 |
+
monkeypatch.setattr(bot, "GLOBAL_QUOTA", _BrokenQuota())
|
| 723 |
+
with caplog.at_level("WARNING", logger="bot"):
|
| 724 |
+
assert lumen_router_config._is_quota_exhausted("openrouter", "m") is False
|
| 725 |
+
assert "Quota state unreadable" in caplog.text
|