SilverElixir commited on
Commit
9bccd1d
·
1 Parent(s): fe3cf23

Ideal routing: voice transcribed into scenario routing, OR qwen newcomer in light chain

Browse files
bot.py CHANGED
@@ -245,6 +245,8 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
245
  # Groq — прямой провайдер лёгкого текста (калибровка 21.09.2026): 1000 запросов/день против 50 у OpenRouter.
246
  GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
247
  GROQ_BASE_URL = "https://api.groq.com/openai/v1"
 
 
248
  # Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
249
  # (100) — при переключении провайдера (/provider или /model) ощущалось резкое
250
  # "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
@@ -713,6 +715,7 @@ __all__ = [
713
  "ask_openrouter_text",
714
  "ask_openrouter_multimodal",
715
  "ask_groq_text",
 
716
  "_gemini_history_contents",
717
  "_build_gemma_identity_contents",
718
  "_build_gemini_call_config",
@@ -945,6 +948,7 @@ from lumen_routes import (
945
  ask_openrouter_text,
946
  ask_openrouter_multimodal,
947
  ask_groq_text,
 
948
  _gemini_history_contents,
949
  _build_gemma_identity_contents,
950
  _build_gemini_call_config,
 
245
  # Groq — прямой провайдер лёгкого текста (калибровка 21.09.2026): 1000 запросов/день против 50 у OpenRouter.
246
  GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
247
  GROQ_BASE_URL = "https://api.groq.com/openai/v1"
248
+ # Голосовые длиннее не транскрибируем (Whisper API всё равно режет 25 МБ) — такие идут прежним путём через Gemini-аудио.
249
+ VOICE_TRANSCRIBE_MAX_BYTES = int(os.getenv("VOICE_TRANSCRIBE_MAX_BYTES", str(10 * 1024 * 1024)))
250
  # Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
251
  # (100) — при переключении провайдера (/provider или /model) ощущалось резкое
252
  # "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
 
715
  "ask_openrouter_text",
716
  "ask_openrouter_multimodal",
717
  "ask_groq_text",
718
+ "_transcribe_audio",
719
  "_gemini_history_contents",
720
  "_build_gemma_identity_contents",
721
  "_build_gemini_call_config",
 
948
  ask_openrouter_text,
949
  ask_openrouter_multimodal,
950
  ask_groq_text,
951
+ _transcribe_audio,
952
  _gemini_history_contents,
953
  _build_gemma_identity_contents,
954
  _build_gemini_call_config,
docs/ARCHITECTURE.md CHANGED
@@ -15,7 +15,7 @@ This document goes one level deeper than the main [README](../README.md) into ho
15
  Routing is fully automatic: the router builds a fresh candidate list for every message:
16
 
17
  - **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
18
- - **A video or audio attachment** → Gemini only. OpenRouter's multimodal models only accept images as base64.
19
  - **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve.
20
  - **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
21
  - **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) get routed to the stronger free OpenRouter models first.
 
15
  Routing is fully automatic: the router builds a fresh candidate list for every message:
16
 
17
  - **A YouTube or website link** → Gemini only. It's the only provider that can read page content (`url_context`) or analyze a video by URL.
18
+ - **A video or audio attachment** → video goes to Gemini only (OpenRouter's multimodal models only accept images as base64). Voice/audio is first transcribed cheaply (Groq Whisper) and the text joins the normal routing below; if transcription fails or is unavailable, the audio goes to Gemini as before.
19
  - **An image attachment, no live-info need** → free OpenRouter vision models first, Gemini as a reserve.
20
  - **Needs current information** (a lightweight keyword heuristic: "now," "today," "price," "who is currently...") → Gemini, prioritizing the models with a real search-grounding quota, then OpenRouter as a reserve, Groq last (knowledge-only answer if both are down).
21
  - **Plain text, no attachments, no freshness need** (the most common case) → Groq first (1000 free requests/day: Qwen, then gpt-oss), then OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) get routed to the stronger free OpenRouter models first.
docs/ENVIRONMENT.md CHANGED
@@ -48,10 +48,11 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
48
  | Variable | Default | Purpose |
49
  |---|---|---|
50
  | `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
51
- | `GROQ_API_KEY` | — | Groq API key (free plan, no card: 1000 req/day). Heads plain-text routes; without it OpenRouter heads them as before. |
52
  | `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
53
  | `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
54
  | `POLLINATIONS_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). Renamed from `HF_IMAGE_MODEL` (Sept 2026) — the backend is Pollinations.ai, not Hugging Face; the old name is no longer read. |
 
55
 
56
  ## Timeouts, rate limiting & routing budgets
57
 
 
48
  | Variable | Default | Purpose |
49
  |---|---|---|
50
  | `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
51
+ | `GROQ_API_KEY` | — | Groq API key (free plan, no card: 1000 req/day). Heads plain-text routes; without it OpenRouter heads them as before. Also powers voice transcription (Whisper). |
52
  | `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
53
  | `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
54
  | `POLLINATIONS_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). Renamed from `HF_IMAGE_MODEL` (Sept 2026) — the backend is Pollinations.ai, not Hugging Face; the old name is no longer read. |
55
+ | `VOICE_TRANSCRIBE_MAX_BYTES` | `10485760` (10 MB) | Voice/audio attachments larger than this skip Whisper transcription and go straight to Gemini audio, as before. |
56
 
57
  ## Timeouts, rate limiting & routing budgets
58
 
lumen_message_core.py CHANGED
@@ -262,6 +262,16 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
262
  message, state, clean_prompt, is_private=is_private,
263
  )
264
 
 
 
 
 
 
 
 
 
 
 
265
  if media_tuple and not clean_prompt:
266
  clean_prompt = _ensure_prompt_text(None, media_tuple[1])
267
  if youtube_url_to_analyze and not clean_prompt:
 
262
  message, state, clean_prompt, is_private=is_private,
263
  )
264
 
265
+ # Голос/аудио: сначала дешёвая транскрибация — дальше текст идёт общим роутингом по
266
+ # сценарию (тяжесть/свежесть определяются по сказанному). Не вышло — падает в прежний
267
+ # путь: аудио напрямую в Gemini (см. is_video_or_audio_media в роутере).
268
+ if media_tuple and media_tuple[1].startswith("audio/"):
269
+ transcript = await bot._transcribe_audio(media_tuple[0], message.chat.id)
270
+ if transcript:
271
+ marked = "[Расшифровка голосового]: " + transcript
272
+ clean_prompt = (clean_prompt + "\n" + marked).strip() if clean_prompt else marked
273
+ media_tuple = None
274
+
275
  if media_tuple and not clean_prompt:
276
  clean_prompt = _ensure_prompt_text(None, media_tuple[1])
277
  if youtube_url_to_analyze and not clean_prompt:
lumen_router_config.py CHANGED
@@ -180,6 +180,7 @@ _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION: list[str] = [
180
  "inclusionai/ling-3.0-flash-fin:free",
181
  "inclusionai/ling-3.0-flash-vl:free",
182
  "liquid/lfm-2.5-2.6b:free",
 
183
  "gemini-3.8-flash",
184
  "openrouter/free",
185
  # Groq-ID без :free-суффикса — те же семейства, что выше через OpenRouter; дословно в ответе им тоже не место.
@@ -320,6 +321,9 @@ _OR_LIGHT_ORDER: list[str] = [
320
  "inclusionai/ling-3.0-flash-sante:free",
321
  "inclusionai/ling-3.0-flash-fin:free",
322
  "liquid/lfm-2.5-2.6b:free",
 
 
 
323
  "openrouter/free",
324
  ]
325
 
 
180
  "inclusionai/ling-3.0-flash-fin:free",
181
  "inclusionai/ling-3.0-flash-vl:free",
182
  "liquid/lfm-2.5-2.6b:free",
183
+ "qwen/qwen3.8-27b:free",
184
  "gemini-3.8-flash",
185
  "openrouter/free",
186
  # Groq-ID без :free-суффикса — те же семейства, что выше через OpenRouter; дословно в ответе им тоже не место.
 
321
  "inclusionai/ling-3.0-flash-sante:free",
322
  "inclusionai/ling-3.0-flash-fin:free",
323
  "liquid/lfm-2.5-2.6b:free",
324
+ # Аудит 21.09.2026 (живой каталог): qwen3.8-27b:free — то же семейство, что калиброванный
325
+ # Groq-Qwen, но другой эндпоинт: некалиброван, поэтому после проверенных, перед резервом.
326
+ "qwen/qwen3.8-27b:free",
327
  "openrouter/free",
328
  ]
329
 
lumen_routes.py CHANGED
@@ -281,6 +281,46 @@ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *,
281
  bot._record_quota_usage("groq", model_trial)
282
  return answer
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  async def ask_openrouter_multimodal(
285
  chat_id: int, user_text: str, media_tuple: tuple[bytes, str], media_filename: str,
286
  model_chain: list[str], *, deadline: float | None = None,
 
281
  bot._record_quota_usage("groq", model_trial)
282
  return answer
283
 
284
+ async def _transcribe_audio(audio_bytes: bytes, chat_id: int) -> str | None:
285
+ """Голос/аудио в текст через Groq Whisper (дешевле Gemini-квоты на порядок). None при
286
+ любой неудаче — вызывающий код молча идёт прежним путём (аудио напрямую в Gemini)."""
287
+ import bot
288
+ if not bot.GROQ_API_KEY or not audio_bytes:
289
+ return None
290
+ if len(audio_bytes) > bot.VOICE_TRANSCRIBE_MAX_BYTES:
291
+ log.warning("[groq] Voice message too big for transcription (%d bytes), leaving to Gemini.", len(audio_bytes))
292
+ return None
293
+ lang = bot._chat_lang(chat_id)
294
+ form = aiohttp.FormData()
295
+ form.add_field("file", audio_bytes, filename="voice.ogg", content_type="audio/ogg")
296
+ form.add_field("model", "whisper-large-v3-turbo")
297
+ if isinstance(lang, str) and len(lang) == 2:
298
+ form.add_field("language", lang)
299
+ try:
300
+ session = await bot._get_http_session()
301
+ async with session.post(
302
+ f"{bot.GROQ_BASE_URL}/audio/transcriptions",
303
+ headers={"Authorization": f"Bearer {bot.GROQ_API_KEY}"},
304
+ data=form,
305
+ timeout=aiohttp.ClientTimeout(total=bot.ROUTE_MODEL_TIMEOUT_SEC, connect=10.0),
306
+ ) as resp:
307
+ if resp.status >= 400:
308
+ body = await resp.read()
309
+ log.warning("[groq] Whisper HTTP %s: %r", resp.status, body[:200])
310
+ return None
311
+ data = await resp.json(content_type=None)
312
+ except Exception as exc:
313
+ exc_str = str(exc) or repr(exc) or exc.__class__.__name__
314
+ if bot.GROQ_API_KEY:
315
+ exc_str = exc_str.replace(bot.GROQ_API_KEY, "<KEY>")
316
+ log.warning("[groq] Whisper request failed: %s", exc_str)
317
+ return None
318
+ text = (data.get("text") or "").strip() if isinstance(data, dict) else ""
319
+ if not text:
320
+ return None
321
+ bot._record_quota_usage("groq", "whisper-large-v3-turbo")
322
+ return text
323
+
324
  async def ask_openrouter_multimodal(
325
  chat_id: int, user_text: str, media_tuple: tuple[bytes, str], media_filename: str,
326
  model_chain: list[str], *, deadline: float | None = None,
tests/test_bot_routes.py CHANGED
@@ -1020,3 +1020,65 @@ def test_route_error_reply_text_maps_groq_error():
1020
  assert "Rate limit reached" not in text
1021
  assert text.strip() != ""
1022
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  assert "Rate limit reached" not in text
1021
  assert text.strip() != ""
1022
 
1023
+
1024
+ def _fake_groq_audio_session(response_json, status=200):
1025
+ class _FakeResp:
1026
+ def __init__(self):
1027
+ self.status = status
1028
+
1029
+ async def __aenter__(self):
1030
+ return self
1031
+
1032
+ async def __aexit__(self, *args):
1033
+ return False
1034
+
1035
+ async def read(self):
1036
+ return b"{}"
1037
+
1038
+ async def json(self, content_type=None):
1039
+ return response_json
1040
+
1041
+ class _FakeSession:
1042
+ def post(self, *args, **kwargs):
1043
+ return _FakeResp()
1044
+
1045
+ async def fake_get_http_session():
1046
+ return _FakeSession()
1047
+
1048
+ return fake_get_http_session
1049
+
1050
+
1051
+ def test_transcribe_audio_success_records_quota(monkeypatch):
1052
+ # Groq Whisper отдал текст — пишем расход groq/whisper и возвращаем текст.
1053
+ monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({"text": " привет мир "}))
1054
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
1055
+ recorded = {}
1056
+ monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model: recorded.setdefault("v", (provider, model)))
1057
+ assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) == "привет мир"
1058
+ assert recorded["v"] == ("groq", "whisper-large-v3-turbo")
1059
+
1060
+
1061
+ def test_transcribe_audio_empty_result_is_none(monkeypatch):
1062
+ # Пустая расшифровка — как неудача: вызывающий код идёт прежним путём.
1063
+ monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({"text": " "}))
1064
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
1065
+ assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
1066
+
1067
+
1068
+ def test_transcribe_audio_no_key_or_oversize_skips_network(monkeypatch):
1069
+ async def must_not_be_called():
1070
+ raise AssertionError("no network without key or for oversize audio")
1071
+
1072
+ monkeypatch.setattr(bot, "_get_http_session", must_not_be_called)
1073
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "")
1074
+ assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
1075
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
1076
+ monkeypatch.setattr(bot, "VOICE_TRANSCRIBE_MAX_BYTES", 4)
1077
+ assert asyncio.run(bot._transcribe_audio(b"too-big-bytes", 123)) is None
1078
+
1079
+
1080
+ def test_transcribe_audio_http_error_is_none(monkeypatch):
1081
+ monkeypatch.setattr(bot, "_get_http_session", _fake_groq_audio_session({}, status=503))
1082
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
1083
+ assert asyncio.run(bot._transcribe_audio(b"ogg-bytes", 123)) is None
1084
+
tests/test_bot_state.py CHANGED
@@ -561,6 +561,59 @@ def test_media_question_without_file_gets_no_file_notice(rate_guard_setup, monke
561
  assert "не выдумывай" in prompt.lower()
562
 
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  def test_ordinary_question_gets_no_file_notice(rate_guard_setup, monkeypatch):
565
  message = rate_guard_setup()
566
  message.text = "столица Венгрии?"
 
561
  assert "не выдумывай" in prompt.lower()
562
 
563
 
564
+ def test_voice_message_transcribed_into_normal_routing(rate_guard_setup, monkeypatch):
565
+ # Войс: транскрибация уходит в общий роутинг как текст, аудио дальше не едет.
566
+ message = rate_guard_setup()
567
+ message.text = ""
568
+ captured = {}
569
+
570
+ async def fake_resolve(message, state, clean_prompt, *, is_private):
571
+ return None, "", "", (b"ogg-bytes", "audio/ogg")
572
+
573
+ async def fake_transcribe(audio_bytes, chat_id):
574
+ assert audio_bytes == b"ogg-bytes"
575
+ return "текст из войса"
576
+
577
+ async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
578
+ captured["prompt"] = ai_prompt
579
+ captured["media"] = kwargs.get("media")
580
+ return "ok", False
581
+
582
+ monkeypatch.setattr(bot, "_resolve_incoming_media", fake_resolve)
583
+ monkeypatch.setattr(bot, "_transcribe_audio", fake_transcribe)
584
+ monkeypatch.setattr(bot, "_run_route", fake_run_route)
585
+ monkeypatch.setattr(bot, "_safe_reply", AsyncMock())
586
+ asyncio.run(bot._handle_message_core(message))
587
+ assert "текст из войса" in captured["prompt"]
588
+ assert captured["media"] is None
589
+ bot.chat_state.pop(123, None)
590
+
591
+
592
+ def test_voice_message_falls_back_to_gemini_audio_on_transcribe_failure(rate_guard_setup, monkeypatch):
593
+ # Whisper упал — войс идёт прежним путём (аудио в Gemini), а не в пустоту.
594
+ message = rate_guard_setup()
595
+ message.text = ""
596
+ captured = {}
597
+
598
+ async def fake_resolve(message, state, clean_prompt, *, is_private):
599
+ return None, "", "", (b"ogg-bytes", "audio/ogg")
600
+
601
+ async def fake_transcribe(audio_bytes, chat_id):
602
+ return None
603
+
604
+ async def fake_run_route(chat_id, ai_prompt, route, message, **kwargs):
605
+ captured["media"] = kwargs.get("media")
606
+ return "ok", False
607
+
608
+ monkeypatch.setattr(bot, "_resolve_incoming_media", fake_resolve)
609
+ monkeypatch.setattr(bot, "_transcribe_audio", fake_transcribe)
610
+ monkeypatch.setattr(bot, "_run_route", fake_run_route)
611
+ monkeypatch.setattr(bot, "_safe_reply", AsyncMock())
612
+ asyncio.run(bot._handle_message_core(message))
613
+ assert captured["media"] == [(b"ogg-bytes", "audio/ogg")]
614
+ bot.chat_state.pop(123, None)
615
+
616
+
617
  def test_ordinary_question_gets_no_file_notice(rate_guard_setup, monkeypatch):
618
  message = rate_guard_setup()
619
  message.text = "столица Венгрии?"