SilverElixir commited on
Commit
9b9fc79
·
1 Parent(s): 02a12e4

Groq provider: qwen head + gpt-oss reserve on light routes (calibrated 21.09.2026), streaming, quota/stats/diag, 8 tests

Browse files
README.md CHANGED
@@ -39,7 +39,7 @@ A Telegram bot styled after Claude's tone and personality (direct, warm, light o
39
 
40
  ## Features
41
 
42
- - **Automatic model routing.** Every message goes to whichever provider actually has what it needs: web search and link reading go to Gemini, everything else defaults to free OpenRouter models, protecting Gemini's tight daily quota.
43
  - **Streaming replies** with a self-calibrating typing-speed pacer, so answers type themselves in instead of landing in a few large chunks.
44
  - **Defenses against prompt injection and identity leaks.** A deterministic input filter plus output scrubbers keep the bot from revealing which model or provider actually answered.
45
  - **TikTok downloads** without watermarks: video, slideshows (including "live" photo slides), and original sound.
 
39
 
40
  ## Features
41
 
42
+ - **Automatic model routing.** Every message goes to whichever provider actually has what it needs: plain text starts at Groq (1000 free requests/day), web search and link reading go to Gemini, OpenRouter covers the rest — protecting Gemini's tight daily quota.
43
  - **Streaming replies** with a self-calibrating typing-speed pacer, so answers type themselves in instead of landing in a few large chunks.
44
  - **Defenses against prompt injection and identity leaks.** A deterministic input filter plus output scrubbers keep the bot from revealing which model or provider actually answered.
45
  - **TikTok downloads** without watermarks: video, slideshows (including "live" photo slides), and original sound.
bot.py CHANGED
@@ -68,7 +68,7 @@ _LOG_LISTENER: logging.handlers.QueueListener | None = None
68
 
69
  _SECRET_NAMES = (
70
  "BOT_TOKEN", "TELEGRAM_TOKEN", "TELEGRAM_BOT_TOKEN", "GEMINI_API_KEY",
71
- "OPENROUTER_API_KEY", "OPENROUTER_KEY", "ADMIN_SECRET_SEED",
72
  "_ADMIN_SECRET_SEED", "WEBHOOK_SECRET", "ADMIN_PANEL_KEY",
73
  "UPSTASH_REDIS_REST_TOKEN", "LUMEN_PROXY_SECRET",
74
  )
@@ -242,6 +242,9 @@ _OPENROUTER_HTTP_REFERER_ENV_SET = bool(os.getenv("OPENROUTER_HTTP_REFERER", "")
242
  OPENROUTER_HTTP_REFERER = os.getenv("OPENROUTER_HTTP_REFERER", f"https://t.me/{BOT_USERNAME}").strip()
243
  OPENROUTER_TITLE = os.getenv("OPENROUTER_TITLE", BOT_USERNAME).strip()
244
  OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
 
 
 
245
  # Раньше у OpenRouter был свой отдельный лимит истории (30), меньший, чем у Gemini
246
  # (100) — при переключении провайдера (/provider или /model) ощущалось резкое
247
  # "обнуление" контекста разговора. Теперь история ОБЩАЯ (см. state["history"] в
@@ -526,6 +529,7 @@ from lumen_router_config import (
526
  _OR_LIGHT_ORDER,
527
  _OR_HEAVY_ORDER,
528
  _OR_VISION_ORDER,
 
529
  GEMINI_TTS_MODELS,
530
  FISH_AUDIO_TTS_MODEL,
531
  FISH_AUDIO_ENABLED,
@@ -657,9 +661,11 @@ __all__ = [
657
  "_pieces_with_waiting_feedback",
658
  "_gemini_stream_pieces",
659
  "_openrouter_stream_pieces",
 
660
  "_run_streaming_reply",
661
  "_try_gemini_streaming",
662
  "_try_openrouter_streaming",
 
663
  # Точка подмены тестов (см. monkeypatch в tests/) — сам код bot.py её
664
  # больше не читает напрямую после выноса стриминга.
665
  "_model_first_chunk_limit",
@@ -693,13 +699,16 @@ __all__ = [
693
  # Имена из lumen_routes.py код bot.py сам не читает — они нужны как `bot.X`
694
  # существующим тестам и `_handle_message_core` ниже.
695
  "OpenRouterAPIError",
 
696
  "_or_request",
 
697
  "_or_extract_text",
698
  "_is_account_wide_or_rate_limit",
699
  "_probe_or_model_liveness",
700
  "_or_chat_completion_with_fallback",
701
  "ask_openrouter_text",
702
  "ask_openrouter_multimodal",
 
703
  "_gemini_history_contents",
704
  "_build_gemma_identity_contents",
705
  "_build_gemini_call_config",
@@ -714,6 +723,7 @@ __all__ = [
714
  "_OR_LIGHT_ORDER",
715
  "_OR_HEAVY_ORDER",
716
  "_OR_VISION_ORDER",
 
717
  "_history_user_text",
718
  # Пространство имён genai-типов для тестов (bot.types.Content/...).
719
  "types",
@@ -917,13 +927,16 @@ from lumen_security import (
917
  # имён, чтобы `bot.X` в тестах и `_handle_message_core` не менялись.
918
  from lumen_routes import (
919
  OpenRouterAPIError,
 
920
  _or_request,
 
921
  _or_extract_text,
922
  _is_account_wide_or_rate_limit,
923
  _probe_or_model_liveness,
924
  _or_chat_completion_with_fallback,
925
  ask_openrouter_text,
926
  ask_openrouter_multimodal,
 
927
  _gemini_history_contents,
928
  _build_gemma_identity_contents,
929
  _build_gemini_call_config,
@@ -1011,9 +1024,11 @@ from lumen_streaming import (
1011
  _pieces_with_waiting_feedback,
1012
  _gemini_stream_pieces,
1013
  _openrouter_stream_pieces,
 
1014
  _run_streaming_reply,
1015
  _try_gemini_streaming,
1016
  _try_openrouter_streaming,
 
1017
  )
1018
 
1019
  # определение ссылок и упоминаний, триггеры draw/tts, категории медиа —
 
68
 
69
  _SECRET_NAMES = (
70
  "BOT_TOKEN", "TELEGRAM_TOKEN", "TELEGRAM_BOT_TOKEN", "GEMINI_API_KEY",
71
+ "OPENROUTER_API_KEY", "OPENROUTER_KEY", "GROQ_API_KEY", "ADMIN_SECRET_SEED",
72
  "_ADMIN_SECRET_SEED", "WEBHOOK_SECRET", "ADMIN_PANEL_KEY",
73
  "UPSTASH_REDIS_REST_TOKEN", "LUMEN_PROXY_SECRET",
74
  )
 
242
  OPENROUTER_HTTP_REFERER = os.getenv("OPENROUTER_HTTP_REFERER", f"https://t.me/{BOT_USERNAME}").strip()
243
  OPENROUTER_TITLE = os.getenv("OPENROUTER_TITLE", BOT_USERNAME).strip()
244
  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"] в
 
529
  _OR_LIGHT_ORDER,
530
  _OR_HEAVY_ORDER,
531
  _OR_VISION_ORDER,
532
+ _GROQ_LIGHT_ORDER,
533
  GEMINI_TTS_MODELS,
534
  FISH_AUDIO_TTS_MODEL,
535
  FISH_AUDIO_ENABLED,
 
661
  "_pieces_with_waiting_feedback",
662
  "_gemini_stream_pieces",
663
  "_openrouter_stream_pieces",
664
+ "_groq_stream_pieces",
665
  "_run_streaming_reply",
666
  "_try_gemini_streaming",
667
  "_try_openrouter_streaming",
668
+ "_try_groq_streaming",
669
  # Точка подмены тестов (см. monkeypatch в tests/) — сам код bot.py её
670
  # больше не читает напрямую после выноса стриминга.
671
  "_model_first_chunk_limit",
 
699
  # Имена из lumen_routes.py код bot.py сам не читает — они нужны как `bot.X`
700
  # существующим тестам и `_handle_message_core` ниже.
701
  "OpenRouterAPIError",
702
+ "GroqAPIError",
703
  "_or_request",
704
+ "_groq_request",
705
  "_or_extract_text",
706
  "_is_account_wide_or_rate_limit",
707
  "_probe_or_model_liveness",
708
  "_or_chat_completion_with_fallback",
709
  "ask_openrouter_text",
710
  "ask_openrouter_multimodal",
711
+ "ask_groq_text",
712
  "_gemini_history_contents",
713
  "_build_gemma_identity_contents",
714
  "_build_gemini_call_config",
 
723
  "_OR_LIGHT_ORDER",
724
  "_OR_HEAVY_ORDER",
725
  "_OR_VISION_ORDER",
726
+ "_GROQ_LIGHT_ORDER",
727
  "_history_user_text",
728
  # Пространство имён genai-типов для тестов (bot.types.Content/...).
729
  "types",
 
927
  # имён, чтобы `bot.X` в тестах и `_handle_message_core` не менялись.
928
  from lumen_routes import (
929
  OpenRouterAPIError,
930
+ GroqAPIError,
931
  _or_request,
932
+ _groq_request,
933
  _or_extract_text,
934
  _is_account_wide_or_rate_limit,
935
  _probe_or_model_liveness,
936
  _or_chat_completion_with_fallback,
937
  ask_openrouter_text,
938
  ask_openrouter_multimodal,
939
+ ask_groq_text,
940
  _gemini_history_contents,
941
  _build_gemma_identity_contents,
942
  _build_gemini_call_config,
 
1024
  _pieces_with_waiting_feedback,
1025
  _gemini_stream_pieces,
1026
  _openrouter_stream_pieces,
1027
+ _groq_stream_pieces,
1028
  _run_streaming_reply,
1029
  _try_gemini_streaming,
1030
  _try_openrouter_streaming,
1031
+ _try_groq_streaming,
1032
  )
1033
 
1034
  # определение ссылок и упоминаний, триггеры draw/tts, категории медиа —
docs/ARCHITECTURE.md CHANGED
@@ -7,7 +7,7 @@ This document goes one level deeper than the main [README](../README.md) into ho
7
  1. Telegram POSTs an update to `/webhook`, authenticated by a secret header (`X-Telegram-Bot-Api-Secret-Token`).
8
  2. `_handle_message_core` in `bot.py` classifies the message: is it a TikTok link, a `/draw` or `/tts` trigger phrase, a reply to earlier media, plain text, or an attachment?
9
  3. `_build_route` (`lumen_router_config.py`) turns that classification into an ordered list of `(provider, model_id)` candidates.
10
- 4. `_run_route` tries the first candidate. If a whole provider's chain fails, it falls back to the other provider, except where that's physically impossible (only Gemini can read links or analyze video/audio).
11
  5. The reply streams into the chat if the request qualifies (see [Streaming](#streaming--typing-pace) below), or is sent as a single message otherwise.
12
 
13
  ## Automatic model routing
@@ -17,10 +17,10 @@ Routing is fully automatic: the router builds a fresh candidate list for every m
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.
21
- - **Plain text, no attachments, no freshness need** (the most common case) → straight to OpenRouter. Heavier requests (code, multi-step reasoning, caught by another lightweight heuristic) get routed to the stronger free OpenRouter models first.
22
 
23
- The reasoning: Gemini's free quota (roughly 20 requests per day for the flagship model) is the scarcest resource in the system, so it's only spent where a capability unique to Gemini is actually needed. Everything else, the bulk of ordinary messages, runs on OpenRouter's much more generous free tier.
24
 
25
  Models that the router should never pick are tracked in a single registry, `_OR_MODEL_HEALTH` (`lumen_router_config.py`), each with a dated reason: the provider dropped the free tier, or the model turned out to be uncensored and a poor fit for Lumen's persona. Nothing is removed on speculation, only on confirmed evidence from production logs (`HTTP 404`, `"no endpoints,"` etc.).
26
 
@@ -32,7 +32,7 @@ Each model is tried exactly once per route, with no retries: a single failure (t
32
 
33
  ## Streaming & typing pace
34
 
35
- For the first candidate in a route (plain text, no attachments, no links), Lumen streams the reply by repeatedly editing one message, the same mechanism for both Gemini (`generate_content_stream`) and OpenRouter (SSE over `chat/completions`).
36
 
37
  Telegram won't let a bot edit one message more than about once a second, so true token-by-token output isn't possible regardless of how fast the model generates text. Instead, `lumen_typing_pace.py` tracks each `provider:model` pair's real observed characters-per-second and reveals a growing slice of the buffered text at that pace, capped by whatever has actually arrived. If a backend returns the whole answer in one large chunk (common for free OpenRouter models, since the same `:free` slug can be served by different backends depending on load), a short "catch-up" phase finishes revealing it smoothly instead of dumping it all at once, capped at `STREAM_TYPING_MAX_CATCHUP_TICKS × STREAM_TYPING_TICK_SEC` seconds.
38
 
@@ -42,7 +42,7 @@ If the streaming attempt for the head-of-route model fails before showing any te
42
 
43
  ### Manual smoke test before touching streaming code
44
 
45
- Streaming is covered only by mocked clients; no test exercises a real Gemini or OpenRouter SSE stream. Before merging changes to `_run_streaming_reply`, `_gemini_stream_pieces`, `_openrouter_stream_pieces`, or `lumen_typing_pace.py`, check by hand in a real chat:
46
 
47
  1. A short message (fits in one edit): once via Gemini, once via OpenRouter.
48
  2. A long message (past `TG_MAX_LEN`): confirm it splits into multiple messages and continues correctly.
 
7
  1. Telegram POSTs an update to `/webhook`, authenticated by a secret header (`X-Telegram-Bot-Api-Secret-Token`).
8
  2. `_handle_message_core` in `bot.py` classifies the message: is it a TikTok link, a `/draw` or `/tts` trigger phrase, a reply to earlier media, plain text, or an attachment?
9
  3. `_build_route` (`lumen_router_config.py`) turns that classification into an ordered list of `(provider, model_id)` candidates.
10
+ 4. `_run_route` tries the first candidate. If a whole provider's chain fails, it falls back to the next provider in the route, except where that's physically impossible (only Gemini can read links or analyze video/audio).
11
  5. The reply streams into the chat if the request qualifies (see [Streaming](#streaming--typing-pace) below), or is sent as a single message otherwise.
12
 
13
  ## Automatic model routing
 
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.
22
 
23
+ The reasoning: Gemini's free quota (roughly 20 requests per day for the flagship model) is the scarcest resource in the system, so it's only spent where a capability unique to Gemini is actually needed. Everything else, the bulk of ordinary messages, runs on Groq (1000/day) and OpenRouter's free tiers.
24
 
25
  Models that the router should never pick are tracked in a single registry, `_OR_MODEL_HEALTH` (`lumen_router_config.py`), each with a dated reason: the provider dropped the free tier, or the model turned out to be uncensored and a poor fit for Lumen's persona. Nothing is removed on speculation, only on confirmed evidence from production logs (`HTTP 404`, `"no endpoints,"` etc.).
26
 
 
32
 
33
  ## Streaming & typing pace
34
 
35
+ For the first candidate in a route (plain text, no attachments, no links), Lumen streams the reply by repeatedly editing one message, the same mechanism for Gemini (`generate_content_stream`), OpenRouter and Groq (SSE over `chat/completions`).
36
 
37
  Telegram won't let a bot edit one message more than about once a second, so true token-by-token output isn't possible regardless of how fast the model generates text. Instead, `lumen_typing_pace.py` tracks each `provider:model` pair's real observed characters-per-second and reveals a growing slice of the buffered text at that pace, capped by whatever has actually arrived. If a backend returns the whole answer in one large chunk (common for free OpenRouter models, since the same `:free` slug can be served by different backends depending on load), a short "catch-up" phase finishes revealing it smoothly instead of dumping it all at once, capped at `STREAM_TYPING_MAX_CATCHUP_TICKS × STREAM_TYPING_TICK_SEC` seconds.
38
 
 
42
 
43
  ### Manual smoke test before touching streaming code
44
 
45
+ Streaming is covered only by mocked clients; no test exercises a real Gemini or OpenRouter SSE stream. Before merging changes to `_run_streaming_reply`, `_gemini_stream_pieces`, `_openrouter_stream_pieces`, `_groq_stream_pieces`, or `lumen_typing_pace.py`, check by hand in a real chat:
46
 
47
  1. A short message (fits in one edit): once via Gemini, once via OpenRouter.
48
  2. A long message (past `TG_MAX_LEN`): confirm it splits into multiple messages and continues correctly.
docs/ENVIRONMENT.md CHANGED
@@ -48,6 +48,7 @@ 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
  | `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
52
  | `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
53
  | `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. |
 
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. |
lumen_admin.py CHANGED
@@ -129,6 +129,7 @@ async def network_diagnostics(request: Request) -> dict[str, Any]:
129
  "gemini_api": "https://generativelanguage.googleapis.com",
130
  "huggingface": "https://huggingface.co",
131
  "openrouter": "https://openrouter.ai",
 
132
  "tikwm": "https://www.tikwm.com",
133
  "pollinations": "https://image.pollinations.ai",
134
  "upstash": bot.UPSTASH_REDIS_REST_URL if bot.USE_UPSTASH else "https://upstash.com",
 
129
  "gemini_api": "https://generativelanguage.googleapis.com",
130
  "huggingface": "https://huggingface.co",
131
  "openrouter": "https://openrouter.ai",
132
+ "groq_api": "https://api.groq.com",
133
  "tikwm": "https://www.tikwm.com",
134
  "pollinations": "https://image.pollinations.ai",
135
  "upstash": bot.UPSTASH_REDIS_REST_URL if bot.USE_UPSTASH else "https://upstash.com",
lumen_commands.py CHANGED
@@ -440,6 +440,14 @@ async def cmd_stats(message: Message) -> None:
440
  ]
441
  or_text = "\n".join(or_lines) or _noData
442
 
 
 
 
 
 
 
 
 
443
  # Состояние прокси — из самого breaker'а (раньше команда лезла в четыре глобала напрямую).
444
  proxy_line = bot._tg_proxy_breaker.status_text()
445
 
@@ -451,7 +459,8 @@ async def cmd_stats(message: Message) -> None:
451
  f"Аптайм процесса: {uptime_str}\n"
452
  f"Счётчики квоты за сутки: {quota_day} (America/Los_Angeles, сбрасываются автоматически)\n\n"
453
  f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
454
- f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}"
 
455
  f"{proxy_line}"
456
  )
457
  await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
 
440
  ]
441
  or_text = "\n".join(or_lines) or _noData
442
 
443
+ # Groq — та же разбивка по моделям (прямой провайдер с собственным дневным лимитом).
444
+ groq_quota = bot.GLOBAL_QUOTA.get("groq", {})
445
+ groq_lines = [
446
+ f" • {mid}: {e.get('used', 0)}{_limitTag if e.get('exhausted_at') else ''}"
447
+ for mid, e in sorted(groq_quota.items(), key=lambda kv: -(kv[1].get("used") or 0))
448
+ ]
449
+ groq_text = "\n".join(groq_lines) or _noData
450
+
451
  # Состояние прокси — из самого breaker'а (раньше команда лезла в четыре глобала напрямую).
452
  proxy_line = bot._tg_proxy_breaker.status_text()
453
 
 
459
  f"Аптайм процесса: {uptime_str}\n"
460
  f"Счётчики квоты за сутки: {quota_day} (America/Los_Angeles, сбрасываются автоматически)\n\n"
461
  f"<b>Gemini — запросов по моделям:</b>\n{gemini_text}\n\n"
462
+ f"<b>OpenRouter — запросов по моделям:</b>\n{or_text}\n\n"
463
+ f"<b>Groq — запросов по моделям:</b>\n{groq_text}"
464
  f"{proxy_line}"
465
  )
466
  await bot._tg_call(message.reply, text, parse_mode=ParseMode.HTML)
lumen_router_config.py CHANGED
@@ -197,6 +197,10 @@ _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION: list[str] = [
197
  "inclusionai/ling-3.0-flash-vl:free",
198
  "liquid/lfm-2.5-2.6b:free",
199
  "gemini-3.8-flash",
 
 
 
 
200
  ]
201
  TEXT_MODEL_ORDER = _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION # алиас для обратной совместимости
202
  # Аудит 02.08.2026 (логи + каталог): laguna-s-2.1 и ling-3.0-flash, калибровку не проходили (только факт free-квоты).
@@ -310,6 +314,17 @@ def _or_route(models: list[str]) -> list[tuple[str, str]]:
310
  def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
311
  return [("gemini", m) for m in models]
312
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  # ── "Лёгкие" запросы — САМЫЙ ЧАСТЫЙ маршрут, целиком OpenRouter (квоту Gemini не трогаем).
315
  #
@@ -468,12 +483,13 @@ def _build_route(
468
  if needs_freshness:
469
  # Текст без вложений, но нужна свежая информация — только у Gemini
470
  # реально есть поиск; OpenRouter в конце как резерв на случай, если
471
- # Gemini исчерпан целиком (без поиска, но хоть какой-то ответ).
472
- return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER)
 
473
 
474
  # Основной случай: обычный текст без вложений/ссылок/признаков нужды в
475
- # интернете — целиком к OpenRouter, Gemini — резерв на случай отказа всей
476
- # цепочки OpenRouter разом.
477
  if is_heavy:
478
  return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
479
- return _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)
 
197
  "inclusionai/ling-3.0-flash-vl:free",
198
  "liquid/lfm-2.5-2.6b:free",
199
  "gemini-3.8-flash",
200
+ "openrouter/free",
201
+ # Groq-ID без :free-суффикса — те же семейства, что выше через OpenRouter; дословно в ответе им тоже не место.
202
+ "qwen/qwen3.8-27b",
203
+ "openai/gpt-oss-120b",
204
  ]
205
  TEXT_MODEL_ORDER = _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION # алиас для обратной совместимости
206
  # Аудит 02.08.2026 (логи + каталог): laguna-s-2.1 и ling-3.0-flash, калибровку не проходили (только факт free-квоты).
 
314
  def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
315
  return [("gemini", m) for m in models]
316
 
317
+ # ── Groq (прямой провайдер, не через OpenRouter) ──
318
+ # Калибровка русского живьём 21.09.2026 (4 пробы на модель через API с VPN): Qwen отвечает чисто и по делу, gpt-oss-120b тоже верен, но представляется ChatGPT от OpenAI (ловит фильтр утечек) и тратит 30-70 reasoning-токенов на ответ — они едят минутный бюджет. Поэтому Qwen голова, gpt-oss второй. Лимиты free-плана по офиц. доке: 30 RPM / 1000 RPD / 8K TPM / 200K TPD на модель.
319
+ GROQ_BASE_URL = "https://api.groq.com/openai/v1"
320
+ _GROQ_LIGHT_ORDER: list[str] = [
321
+ "qwen/qwen3.8-27b",
322
+ "openai/gpt-oss-120b",
323
+ ]
324
+
325
+ def _groq_route(models: list[str]) -> list[tuple[str, str]]:
326
+ return [("groq", m) for m in models]
327
+
328
 
329
  # ── "Лёгкие" запросы — САМЫЙ ЧАСТЫЙ маршрут, целиком OpenRouter (квоту Gemini не трогаем).
330
  #
 
483
  if needs_freshness:
484
  # Текст без вложений, но нужна свежая информация — только у Gemini
485
  # реально есть поиск; OpenRouter в конце как резерв на случай, если
486
+ # Gemini исчерпан целиком (без поиска, но хоть какой-то ответ), Groq —
487
+ # последним (ответ по знаниям, если легли оба).
488
+ return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER) + _groq_route(_GROQ_LIGHT_ORDER)
489
 
490
  # Основной случай: обычный текст без вложений/ссылок/признаков нужды в
491
+ # интернете — сначала Groq (1000/день против 50 у OpenRouter — главный объём),
492
+ # дальше OpenRouter, Gemini — резерв на случай отказа обоих разом.
493
  if is_heavy:
494
  return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
495
+ return _groq_route(_GROQ_LIGHT_ORDER) + _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)
lumen_routes.py CHANGED
@@ -50,6 +50,41 @@ class OpenRouterAPIError(RuntimeError):
50
  self.status_code = status_code
51
  self.payload = payload
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  async def _or_request(path: str, method: str = "GET", *, json_body: dict | None = None) -> Any:
54
  import bot
55
  if not bot.OPENROUTER_API_KEY:
@@ -106,15 +141,18 @@ async def _probe_or_model_liveness() -> None:
106
  return
107
  day_idx = date.today().timetuple().tm_yday
108
  lists = {
109
- "_OR_LIGHT_ORDER": _OR_LIGHT_ORDER,
110
- "_OR_HEAVY_ORDER": _OR_HEAVY_ORDER,
111
- "_OR_VISION_ORDER": _OR_VISION_ORDER,
112
  }
113
- heads = {name: models[day_idx % len(models)] for name, models in lists.items() if models}
114
- for list_name, model_id in heads.items():
 
 
 
115
  try:
116
  payload = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "stream": False}
117
- await bot._or_request("chat/completions", "POST", json_body=payload)
118
  except Exception as exc:
119
  txt = bot._error_text(exc).strip() or exc.__class__.__name__
120
  kind = bot._classify_model_error(bot._error_status(exc, txt), txt)
@@ -188,10 +226,61 @@ async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[st
188
  history.append({"role": "assistant", "content": answer})
189
  ctx.clear()
190
  if len(history) > bot.SHARED_HISTORY_MAX_LEN:
191
- del history[:-bot.SHARED_HISTORY_MAX_LEN]
192
  bot._record_quota_usage("openrouter", model_trial)
193
  return answer
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  async def ask_openrouter_multimodal(
196
  chat_id: int, user_text: str, media_tuple: tuple[bytes, str], media_filename: str,
197
  model_chain: list[str], *, deadline: float | None = None,
@@ -556,6 +645,8 @@ def _route_error_reply_text(exc: Exception, head_model: str, *, youtube_url_to_a
556
  return bot._lang_t(lang, "err_budget")
557
  if isinstance(exc, bot.OpenRouterAPIError):
558
  return bot._or_error_msg(exc, "text", lang)
 
 
559
  return bot._gemini_error_msg(exc, head_model, lang)
560
 
561
 
@@ -581,11 +672,12 @@ async def _run_route(
581
  route = _reorder_route_by_speed(route)
582
  deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
583
 
584
- groups: dict[str, list[str]] = {"gemini": [], "openrouter": []}
585
  for provider, model_id in route:
586
- groups[provider].append(model_id)
587
  first_provider = route[0][0]
588
- provider_order = [first_provider, "openrouter" if first_provider == "gemini" else "gemini"]
 
589
 
590
  # Стримим только голову маршрута; плейсхолдер "…" переиспользуем под финальный текст следующей модели, а не сносим.
591
  tried_stream_model: str | None = None
@@ -607,6 +699,13 @@ async def _run_route(
607
  return streamed, True
608
  tried_stream_model, tried_stream_provider = head_model, "openrouter"
609
  reusable_placeholder = placeholder
 
 
 
 
 
 
 
610
 
611
  is_video_or_audio = bool(media) and not media[0][1].startswith("image/")
612
  last_exc: Exception | None = None
@@ -625,6 +724,8 @@ async def _run_route(
625
  try:
626
  if provider == "gemini":
627
  ans = await bot.ask_gemini(chat_id, ai_prompt, media=media, youtube_url=youtube_url, model_chain=ids, deadline=deadline)
 
 
628
  elif media:
629
  ans = await bot.ask_openrouter_multimodal(chat_id, ai_prompt, media[0], media_filename, model_chain=ids, deadline=deadline)
630
  else:
 
50
  self.status_code = status_code
51
  self.payload = payload
52
 
53
+ class GroqAPIError(RuntimeError):
54
+ """То же, что OpenRouterAPIError, для прямого Groq-эндпоинта (статус кладём рядом — его читает _error_status/_classify_model_error)."""
55
+ def __init__(self, message: str, status_code: int | None = None, payload: Any = None) -> None:
56
+ super().__init__(message)
57
+ self.status_code = status_code
58
+ self.payload = payload
59
+
60
+ async def _groq_request(path: str, method: str = "GET", *, json_body: dict | None = None) -> Any:
61
+ """Прямой запрос к Groq (OpenAI-совместимый API) — по образцу _or_request: тот же бюджет попытки, та же вычистка ключа из ошибок."""
62
+ import bot
63
+ if not bot.GROQ_API_KEY:
64
+ raise bot.GroqAPIError("GROQ_API_KEY is not set")
65
+ headers = {"Authorization": f"Bearer {bot.GROQ_API_KEY}"}
66
+ if json_body is not None:
67
+ headers["Content-Type"] = "application/json"
68
+ session = await bot._get_http_session()
69
+ url = f"{bot.GROQ_BASE_URL}/{path.lstrip('/')}"
70
+ try:
71
+ async with session.request(
72
+ method.upper(), url, headers=headers, json=json_body,
73
+ timeout=aiohttp.ClientTimeout(total=bot.ROUTE_MODEL_TIMEOUT_SEC, connect=10.0)
74
+ ) as resp:
75
+ if resp.status >= 400:
76
+ payload = await resp.json(content_type=None)
77
+ msg = payload.get("error", {}).get("message") or f"HTTP {resp.status}"
78
+ raise bot.GroqAPIError(msg, status_code=resp.status, payload=payload)
79
+ return await resp.json(content_type=None)
80
+ except bot.GroqAPIError:
81
+ raise
82
+ except Exception as exc:
83
+ exc_str = str(exc) or repr(exc) or exc.__class__.__name__
84
+ if bot.GROQ_API_KEY:
85
+ exc_str = exc_str.replace(bot.GROQ_API_KEY, "<KEY>")
86
+ raise bot.GroqAPIError(f"Groq network error: {exc_str}") from exc
87
+
88
  async def _or_request(path: str, method: str = "GET", *, json_body: dict | None = None) -> Any:
89
  import bot
90
  if not bot.OPENROUTER_API_KEY:
 
141
  return
142
  day_idx = date.today().timetuple().tm_yday
143
  lists = {
144
+ "_OR_LIGHT_ORDER": (_OR_LIGHT_ORDER, bot._or_request),
145
+ "_OR_HEAVY_ORDER": (_OR_HEAVY_ORDER, bot._or_request),
146
+ "_OR_VISION_ORDER": (_OR_VISION_ORDER, bot._or_request),
147
  }
148
+ if bot.GROQ_API_KEY:
149
+ from lumen_router_config import _GROQ_LIGHT_ORDER
150
+ lists["_GROQ_LIGHT_ORDER"] = (_GROQ_LIGHT_ORDER, bot._groq_request)
151
+ heads = {name: (models[day_idx % len(models)], req) for name, (models, req) in lists.items() if models}
152
+ for list_name, (model_id, req_fn) in heads.items():
153
  try:
154
  payload = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "stream": False}
155
+ await req_fn("chat/completions", "POST", json_body=payload)
156
  except Exception as exc:
157
  txt = bot._error_text(exc).strip() or exc.__class__.__name__
158
  kind = bot._classify_model_error(bot._error_status(exc, txt), txt)
 
226
  history.append({"role": "assistant", "content": answer})
227
  ctx.clear()
228
  if len(history) > bot.SHARED_HISTORY_MAX_LEN:
229
+ del history[:-bot.SHARED_HISTORY_MAX_LEN]
230
  bot._record_quota_usage("openrouter", model_trial)
231
  return answer
232
 
233
+ async def ask_groq_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
234
+ """Обычный текстовый запрос через прямой Groq — по образцу ask_openrouter_text: одна попытка на модель, скраб утечек, расход в квоту "groq". Сообщения строит общий _build_openrouter_turn_messages (тот же OpenAI-формат)."""
235
+ import bot
236
+ from lumen_router_config import _GROQ_LIGHT_ORDER
237
+ state = bot.get_state(chat_id)
238
+ history = state.setdefault("history", [])
239
+ ctx = state.get("ctx", deque())
240
+ # Дедупликация с сохранением приоритета роутера; запасной — голова актуального _GROQ_LIGHT_ORDER.
241
+ trial_models = list(dict.fromkeys(model_chain)) or [_GROQ_LIGHT_ORDER[0]]
242
+ primary_model_id = trial_models[0]
243
+ messages = bot._build_openrouter_turn_messages(chat_id, user_text, primary_model_id)
244
+
245
+ last_exc: Exception | None = None
246
+ tried: list[str] = []
247
+ for model_trial in trial_models:
248
+ if deadline is not None and time.monotonic() > deadline:
249
+ log.warning('[groq] Route time budget exhausted before model %s. Tried: %s', model_trial, ", ".join(tried) or "none")
250
+ raise bot.RouteBudgetExceededError(tried)
251
+ tried.append(model_trial)
252
+ messages[0]["content"] = bot.get_system_prompt(model_trial)
253
+ attempt_start = time.monotonic()
254
+ try:
255
+ payload = {"model": model_trial, "messages": messages, "stream": False}
256
+ resp = await bot._groq_request("chat/completions", "POST", json_body=payload)
257
+ choices = resp.get("choices") or []
258
+ answer = ""
259
+ if choices:
260
+ answer = bot._or_extract_text(choices[0].get("message") or "")
261
+ answer = answer.strip()
262
+ if not answer:
263
+ # Пустой ответ — повод попробовать следующую модель (тот же прод-кейс 17.09.2026, что у OR/Gemini).
264
+ raise RuntimeError(f"Model {model_trial} returned an empty response")
265
+ answer = _scrub_identity_leak(answer, source=f"groq_chat_completion:{model_trial}")
266
+ log.info('[groq] Successful response from model %s (primary=%s, models tried: %d)', model_trial, primary_model_id, len(tried))
267
+ _record_model_latency(_model_speed_key("groq", model_trial), total_sec=time.monotonic() - attempt_start)
268
+ break
269
+ except Exception as exc:
270
+ last_exc = exc
271
+ log.warning("[groq] Model %s failed: %s. Switching to next candidate...", model_trial, str(last_exc) or last_exc.__class__.__name__)
272
+ else:
273
+ raise last_exc or RuntimeError("No candidate model returned an answer.")
274
+
275
+ # В историю — чистый текст пользователя, как у остальных провайдеров.
276
+ history.append({"role": "user", "content": user_text})
277
+ history.append({"role": "assistant", "content": answer})
278
+ ctx.clear()
279
+ if len(history) > bot.SHARED_HISTORY_MAX_LEN:
280
+ del history[:-bot.SHARED_HISTORY_MAX_LEN]
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,
 
645
  return bot._lang_t(lang, "err_budget")
646
  if isinstance(exc, bot.OpenRouterAPIError):
647
  return bot._or_error_msg(exc, "text", lang)
648
+ if isinstance(exc, bot.GroqAPIError):
649
+ return bot._or_error_msg(exc, "groq", lang)
650
  return bot._gemini_error_msg(exc, head_model, lang)
651
 
652
 
 
672
  route = _reorder_route_by_speed(route)
673
  deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
674
 
675
+ groups: dict[str, list[str]] = {"gemini": [], "openrouter": [], "groq": []}
676
  for provider, model_id in route:
677
+ groups.setdefault(provider, []).append(model_id)
678
  first_provider = route[0][0]
679
+ # Порядок провайдеров — по первому появлению в маршруте (для прежних двухпровайдерных маршрутов то же самое: голова + второй).
680
+ provider_order = list(dict.fromkeys(p for p, _ in route))
681
 
682
  # Стримим только голову маршрута; плейсхолдер "…" переиспользуем под финальный текст следующей модели, а не сносим.
683
  tried_stream_model: str | None = None
 
699
  return streamed, True
700
  tried_stream_model, tried_stream_provider = head_model, "openrouter"
701
  reusable_placeholder = placeholder
702
+ elif first_provider == "groq":
703
+ streamed, placeholder = await bot._try_groq_streaming(chat_id, ai_prompt, message, head_model)
704
+ if streamed is not None:
705
+ log.info('[router] chat=%s response received via streaming (groq:%s)', chat_id, head_model)
706
+ return streamed, True
707
+ tried_stream_model, tried_stream_provider = head_model, "groq"
708
+ reusable_placeholder = placeholder
709
 
710
  is_video_or_audio = bool(media) and not media[0][1].startswith("image/")
711
  last_exc: Exception | None = None
 
724
  try:
725
  if provider == "gemini":
726
  ans = await bot.ask_gemini(chat_id, ai_prompt, media=media, youtube_url=youtube_url, model_chain=ids, deadline=deadline)
727
+ elif provider == "groq":
728
+ ans = await bot.ask_groq_text(chat_id, ai_prompt, model_chain=ids, deadline=deadline)
729
  elif media:
730
  ans = await bot.ask_openrouter_multimodal(chat_id, ai_prompt, media[0], media_filename, model_chain=ids, deadline=deadline)
731
  else:
lumen_streaming.py CHANGED
@@ -145,6 +145,52 @@ async def _openrouter_stream_pieces(model_id: str, messages: list[dict]):
145
  if piece:
146
  yield piece
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  async def _run_streaming_reply(
149
  chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
150
  ) -> tuple[str | None, Message | None]:
@@ -334,3 +380,11 @@ async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Messa
334
  # Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
335
  piece_agen = bot._openrouter_stream_pieces(model_id, messages)
336
  return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen)
 
 
 
 
 
 
 
 
 
145
  if piece:
146
  yield piece
147
 
148
+ async def _groq_stream_pieces(model_id: str, messages: list[dict]):
149
+ """Куски от Groq: тот же OpenAI-SSE, что у OpenRouter (Groq — OpenAI-совместимый API). Таймаут на строку — STREAM_CHUNK_TIMEOUT_SEC."""
150
+ import bot
151
+ if not bot.GROQ_API_KEY:
152
+ raise bot.GroqAPIError("GROQ_API_KEY is not set")
153
+ headers = {
154
+ "Authorization": f"Bearer {bot.GROQ_API_KEY}",
155
+ "Content-Type": "application/json",
156
+ }
157
+ session = await bot._get_http_session()
158
+ url = f"{bot.GROQ_BASE_URL}/chat/completions"
159
+ payload = {"model": model_id, "messages": messages, "stream": True}
160
+ async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=None, connect=12.0)) as resp:
161
+ if resp.status >= 400:
162
+ body = await resp.read()
163
+ raise bot.GroqAPIError(f"HTTP {resp.status}: {body[:300]!r}", status_code=resp.status)
164
+ line_iter = resp.content.__aiter__()
165
+ while True:
166
+ try:
167
+ raw_line = await asyncio.wait_for(line_iter.__anext__(), timeout=bot.STREAM_CHUNK_TIMEOUT_SEC)
168
+ except StopAsyncIteration:
169
+ break
170
+ line = raw_line.decode("utf-8", errors="ignore").strip()
171
+ if not line or not line.startswith("data:"):
172
+ continue
173
+ data_str = line[len("data:"):].strip()
174
+ if data_str == "[DONE]":
175
+ break
176
+ try:
177
+ obj = json.loads(data_str)
178
+ except Exception:
179
+ continue
180
+ # Ошибка внутри SSE-чанка — как у OpenRouter: поднимаем, дальше штатно (ранний сбой — откат, поздний — пометка).
181
+ err_obj = obj.get("error")
182
+ if err_obj:
183
+ err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
184
+ err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
185
+ raise bot.GroqAPIError(err_msg or "Groq returned an error in the stream body", status_code=err_code)
186
+ choices = obj.get("choices") or []
187
+ if not choices:
188
+ continue
189
+ delta = choices[0].get("delta") or {}
190
+ piece = delta.get("content") or ""
191
+ if piece:
192
+ yield piece
193
+
194
  async def _run_streaming_reply(
195
  chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
196
  ) -> tuple[str | None, Message | None]:
 
380
  # Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
381
  piece_agen = bot._openrouter_stream_pieces(model_id, messages)
382
  return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen)
383
+
384
+ async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str) -> tuple[str | None, Message | None]:
385
+ """Обёртка _run_streaming_reply для Groq (SSE через chat/completions)."""
386
+ import bot
387
+ messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
388
+ # Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
389
+ piece_agen = bot._groq_stream_pieces(model_id, messages)
390
+ return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen)
tests/test_bot_routes.py CHANGED
@@ -447,6 +447,34 @@ def test_run_route_falls_back_from_failed_openrouter_stream_to_non_streaming():
447
  bot.ask_openrouter_text = original_or_text
448
 
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  def test_run_route_reuses_stream_placeholder_when_fallback_succeeds():
451
  # Регрессия на реальный найденный при тестировании баг: раньше при неудачном
452
  # стриме плейсхолдер "…" тут же удалялся, а следующая модель отправляла
@@ -919,3 +947,75 @@ def test_system_prompt_en_keeps_key_guards():
919
  def test_get_system_prompt_header_english():
920
  assert "CURRENT TIME INFORMATION" in bot.get_system_prompt()
921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  bot.ask_openrouter_text = original_or_text
448
 
449
 
450
+ def test_run_route_tries_groq_head_with_stream_fallback_to_text():
451
+ # Groq-подключение 21.09.2026: голова-groq стримится через _try_groq_streaming, при раннем сбое — обычный ask_groq_text без пере-пробы упавшей модели.
452
+ chat_id = 999703
453
+
454
+ async def fake_groq_stream_fail(cid, prompt, message, model_id):
455
+ return None, None
456
+
457
+ calls = []
458
+
459
+ async def fake_groq_text(cid, prompt, model_chain, deadline=None):
460
+ calls.append(model_chain)
461
+ return "Ответ Groq без стрима"
462
+
463
+ original_stream = bot._try_groq_streaming
464
+ original_groq_text = bot.ask_groq_text
465
+ bot._try_groq_streaming = fake_groq_stream_fail
466
+ bot.ask_groq_text = fake_groq_text
467
+ try:
468
+ route = [("groq", "qwen/qwen3.8-27b"), ("groq", "openai/gpt-oss-120b")]
469
+ ans, sent = asyncio.run(bot._run_route(chat_id, "привет", route, message=None, allow_stream=True))
470
+ assert ans == "Ответ Groq без стрима"
471
+ assert sent is False
472
+ assert calls[0] == ["openai/gpt-oss-120b"]
473
+ finally:
474
+ bot._try_groq_streaming = original_stream
475
+ bot.ask_groq_text = original_groq_text
476
+
477
+
478
  def test_run_route_reuses_stream_placeholder_when_fallback_succeeds():
479
  # Регрессия на реальный найденный при тестировании баг: раньше при неудачном
480
  # стриме плейсхолдер "…" тут же удалялся, а следующая модель отправляла
 
947
  def test_get_system_prompt_header_english():
948
  assert "CURRENT TIME INFORMATION" in bot.get_system_prompt()
949
 
950
+
951
+ def test_ask_groq_text_success_records_groq_quota(monkeypatch):
952
+ # Groq-подключение 21.09.2026: успешный ответ пишется в историю и в квоту "groq", как у остальных провайдеров.
953
+ from collections import deque
954
+ chat_id = 999701
955
+ monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
956
+
957
+ async def fake_groq_request(path, method="GET", *, json_body=None):
958
+ assert json_body["model"] == "qwen/qwen3.8-27b"
959
+ return {"choices": [{"message": {"content": "Канберра."}}]}
960
+
961
+ monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
962
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
963
+ recorded = {}
964
+ monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model: recorded.setdefault("v", (provider, model)))
965
+ answer = asyncio.run(bot.ask_groq_text(chat_id, "Столица Австралии?", model_chain=["qwen/qwen3.8-27b"]))
966
+ assert answer == "Канберра."
967
+ assert recorded["v"] == ("groq", "qwen/qwen3.8-27b")
968
+
969
+
970
+ def test_ask_groq_text_falls_back_to_next_model(monkeypatch):
971
+ # Одна попытка на модель: первая падает — идём на вторую, а не ретраим.
972
+ from collections import deque
973
+ chat_id = 999702
974
+ monkeypatch.setattr(bot, "get_state", lambda cid: {"history": [], "ctx": deque()})
975
+ seen = []
976
+
977
+ async def fake_groq_request(path, method="GET", *, json_body=None):
978
+ seen.append(json_body["model"])
979
+ if json_body["model"] == "qwen/qwen3.8-27b":
980
+ raise bot.GroqAPIError("overloaded", status_code=503)
981
+ return {"choices": [{"message": {"content": "ok"}}]}
982
+
983
+ monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
984
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-key")
985
+ monkeypatch.setattr(bot, "_record_quota_usage", lambda provider, model: None)
986
+ answer = asyncio.run(bot.ask_groq_text(chat_id, "привет", model_chain=["qwen/qwen3.8-27b", "openai/gpt-oss-120b"]))
987
+ assert answer == "ok"
988
+ assert seen == ["qwen/qwen3.8-27b", "openai/gpt-oss-120b"]
989
+
990
+
991
+ def test_groq_request_requires_key(monkeypatch):
992
+ # Без GROQ_API_KEY — понятная ошибка, а не сетевой вызов в никуда.
993
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "")
994
+ with pytest.raises(bot.GroqAPIError):
995
+ asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
996
+
997
+
998
+ def test_groq_request_scrubs_api_key_from_network_exception_message(monkeypatch):
999
+ # Тот же defense-in-depth, что у _or_request: ключ не должен светиться в тексте ошибок.
1000
+ class _FakeSessionRaisingWithKey:
1001
+ def request(self, *args, **kwargs):
1002
+ raise RuntimeError("connection failed, headers: Bearer fake-groq-key-123")
1003
+
1004
+ async def fake_get_http_session():
1005
+ return _FakeSessionRaisingWithKey()
1006
+
1007
+ monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
1008
+ monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-groq-key-123")
1009
+ with pytest.raises(bot.GroqAPIError) as exc_info:
1010
+ asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
1011
+ assert "fake-groq-key-123" not in str(exc_info.value)
1012
+ assert "<KEY>" in str(exc_info.value)
1013
+
1014
+
1015
+ def test_route_error_reply_text_maps_groq_error():
1016
+ # Ошибка Groq — пользовательский текст через общую классификацию, без сырого API.
1017
+ exc = bot.GroqAPIError("Rate limit reached", status_code=429)
1018
+ text = bot._route_error_reply_text(exc, "qwen/qwen3.8-27b", youtube_url_to_analyze=None, lang="en")
1019
+ assert "Rate limit reached" not in text
1020
+ assert text.strip() != ""
1021
+
tests/test_bot_streaming.py CHANGED
@@ -179,6 +179,36 @@ def test_openrouter_stream_pieces_parses_sse_chunks():
179
  bot.OPENROUTER_API_KEY = original_key
180
 
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  def test_openrouter_stream_pieces_raises_on_http_error_status():
183
  fake_resp = _FakeSSEResponse([], status=500)
184
  fake_session = _FakeSessionForSSE(fake_resp)
 
179
  bot.OPENROUTER_API_KEY = original_key
180
 
181
 
182
+ def test_groq_stream_pieces_parses_sse_chunks():
183
+ # Groq-подключение 21.09.2026: тот же OpenAI-SSE, что у OpenRouter — куски собираются, [DONE] завершает.
184
+ lines = [
185
+ 'data: {"choices":[{"delta":{"content":"Кан"}}]}\n'.encode("utf-8"),
186
+ 'data: {"choices":[{"delta":{"content":"берра"}}]}\n'.encode("utf-8"),
187
+ b"data: [DONE]\n",
188
+ ]
189
+ fake_resp = _FakeSSEResponse(lines)
190
+ fake_session = _FakeSessionForSSE(fake_resp)
191
+
192
+ async def fake_get_http_session():
193
+ return fake_session
194
+
195
+ original_get_session = bot._get_http_session
196
+ original_key = bot.GROQ_API_KEY
197
+ bot._get_http_session = fake_get_http_session
198
+ bot.GROQ_API_KEY = "fake-key"
199
+ try:
200
+ async def collect():
201
+ pieces = []
202
+ async for piece in bot._groq_stream_pieces("qwen/qwen3.8-27b", [{"role": "user", "content": "hi"}]):
203
+ pieces.append(piece)
204
+ return pieces
205
+ pieces = asyncio.run(collect())
206
+ assert pieces == ["Кан", "берра"]
207
+ finally:
208
+ bot._get_http_session = original_get_session
209
+ bot.GROQ_API_KEY = original_key
210
+
211
+
212
  def test_openrouter_stream_pieces_raises_on_http_error_status():
213
  fake_resp = _FakeSSEResponse([], status=500)
214
  fake_session = _FakeSessionForSSE(fake_resp)
tests/test_lumen_router_config.py CHANGED
@@ -114,13 +114,16 @@ def test_build_route_freshness_query_prioritizes_search_capable_gemini_models():
114
  assert route[0][0] == "gemini"
115
  # OpenRouter должен присутствовать как резерв на случай полного отказа Gemini.
116
  assert any(p == "openrouter" for p, _ in route)
 
 
117
 
118
 
119
- def test_build_route_plain_text_prefers_openrouter_to_save_gemini_quota():
120
- # Основной сценарий из требования: обычный текст без вложений/ссылок/нужды
121
- # в интернете — должен идти в OpenRouter первым делом, а не в Gemini.
122
  route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
123
- assert route[0][0] == "openrouter"
 
124
  assert any(p == "gemini" for p, _ in route) # Gemini всё ещё есть как резерв
125
 
126
 
 
114
  assert route[0][0] == "gemini"
115
  # OpenRouter должен присутствовать как резерв на случай полного отказа Gemini.
116
  assert any(p == "openrouter" for p, _ in route)
117
+ # Groq — последним резервом (ответ по знаниям, если легли оба).
118
+ assert route[-1][0] == "groq"
119
 
120
 
121
+ def test_build_route_plain_text_prefers_groq_to_save_quotas():
122
+ # Обычный текст — сначала Groq (1000/день против 50 у OpenRouter, калибровка 21.09.2026),
123
+ # дальше OpenRouter, Gemini — резервом.
124
  route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=False, needs_freshness=False)
125
+ assert route[0] == ("groq", "qwen/qwen3.8-27b")
126
+ assert any(p == "openrouter" for p, _ in route)
127
  assert any(p == "gemini" for p, _ in route) # Gemini всё ещё есть как резерв
128
 
129