Spaces:
Running
Running
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 +1 -1
- bot.py +16 -1
- docs/ARCHITECTURE.md +6 -6
- docs/ENVIRONMENT.md +1 -0
- lumen_admin.py +1 -0
- lumen_commands.py +10 -1
- lumen_router_config.py +21 -5
- lumen_routes.py +111 -10
- lumen_streaming.py +54 -0
- tests/test_bot_routes.py +100 -0
- tests/test_bot_streaming.py +30 -0
- tests/test_lumen_router_config.py +7 -4
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,
|
| 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
|
| 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) →
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 473 |
|
| 474 |
# Основной случай: обычный текст без вложений/ссылок/признаков нужды в
|
| 475 |
-
# интернете —
|
| 476 |
-
#
|
| 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 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
| 115 |
try:
|
| 116 |
payload = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "stream": False}
|
| 117 |
-
await
|
| 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 |
-
|
| 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
|
| 587 |
first_provider = route[0][0]
|
| 588 |
-
|
|
|
|
| 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
|
| 120 |
-
# О
|
| 121 |
-
#
|
| 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]
|
|
|
|
| 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 |
|