Spaces:
Running
Running
SilverElixir commited on
Commit ·
b367f07
1
Parent(s): 00b6f48
Hide model internals from users, English internals in logs, Claude-style tone, live-log model updates
Browse files- bot.py +67 -57
- lumen_router_config.py +24 -7
- lumen_security.py +3 -3
- system_prompt.py +1 -1
- test_bot.py +44 -1
- test_lumen_router_config.py +23 -4
bot.py
CHANGED
|
@@ -12,7 +12,6 @@ import base64
|
|
| 12 |
import contextlib
|
| 13 |
import hashlib
|
| 14 |
import hmac
|
| 15 |
-
import html as _html_mod
|
| 16 |
import json
|
| 17 |
import logging
|
| 18 |
import logging.handlers
|
|
@@ -732,7 +731,7 @@ async def _tg_call(method: Any, *args: Any, call_timeout: float | None = None, r
|
|
| 732 |
|
| 733 |
async def telegram_api_call(method: str, payload: dict, *, request_timeout: float | None = None) -> Any:
|
| 734 |
if _tg_proxy_breaker.is_down(time.monotonic()):
|
| 735 |
-
raise RuntimeError(f"Telegram API {method}:
|
| 736 |
url = f"{TELEGRAM_API_BASE_URL}/bot{BOT_TOKEN}/{method}"
|
| 737 |
session = await _get_telegram_session()
|
| 738 |
pruned = _json_prune_defaults(payload)
|
|
@@ -891,13 +890,17 @@ def _classify_model_error(status: int | None, text: str) -> str:
|
|
| 891 |
# убраны целиком — обе команды удалены (см. README, "Автоматический выбор
|
| 892 |
# модели"), реального способа переключиться вручную больше нет, и предлагать
|
| 893 |
# его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
_MODEL_ERROR_MESSAGES: dict[str, str] = {
|
| 895 |
-
"rate_limit": "Лимит запросов
|
| 896 |
-
"paid": "
|
| 897 |
-
"forbidden": "Временная ошибка доступа к сервису. Попробуй
|
| 898 |
-
"unavailable": "
|
| 899 |
}
|
| 900 |
-
_MODEL_ERROR_FALLBACK_MSG = "Временная ошибка сервиса. Попробуй
|
| 901 |
|
| 902 |
def _model_error_text(kind: str) -> str:
|
| 903 |
return _MODEL_ERROR_MESSAGES.get(kind, _MODEL_ERROR_FALLBACK_MSG)
|
|
@@ -931,8 +934,8 @@ def _gemini_error_msg(e: Exception, model_id: str) -> str:
|
|
| 931 |
return str(e)
|
| 932 |
if isinstance(e, GeminiAllModelsExhaustedError):
|
| 933 |
return (
|
| 934 |
-
"
|
| 935 |
-
"
|
| 936 |
)
|
| 937 |
txt = _error_text(e).strip() or e.__class__.__name__
|
| 938 |
status = _error_status(e, txt)
|
|
@@ -1879,12 +1882,14 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
|
|
| 1879 |
# в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
|
| 1880 |
# (inline_draw ниже) теперь сам получает сессию и передаёт её. Публичные имена и
|
| 1881 |
# остальное поведение не изменились.
|
|
|
|
|
|
|
|
|
|
| 1882 |
from lumen_images import (
|
| 1883 |
DEFAULT_HF_IMAGE_MODEL,
|
| 1884 |
HF_IMAGE_MODELS,
|
| 1885 |
_pick_image_model,
|
| 1886 |
_hf_text_to_image,
|
| 1887 |
-
_image_model_label,
|
| 1888 |
)
|
| 1889 |
|
| 1890 |
# DEFAULT_HF_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
|
|
@@ -2182,7 +2187,7 @@ class OpenRouterAPIError(RuntimeError):
|
|
| 2182 |
|
| 2183 |
async def _or_request(path: str, method: str = "GET", *, json_body: dict | None = None) -> Any:
|
| 2184 |
if not OPENROUTER_API_KEY:
|
| 2185 |
-
raise OpenRouterAPIError("OPENROUTER_API_KEY
|
| 2186 |
headers = {
|
| 2187 |
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 2188 |
"HTTP-Referer": OPENROUTER_HTTP_REFERER,
|
|
@@ -2223,7 +2228,7 @@ async def _or_request(path: str, method: str = "GET", *, json_body: dict | None
|
|
| 2223 |
# которое могло бы процитировать заголовки запроса целиком.
|
| 2224 |
if OPENROUTER_API_KEY:
|
| 2225 |
exc_str = exc_str.replace(OPENROUTER_API_KEY, "<KEY>")
|
| 2226 |
-
raise OpenRouterAPIError(f"
|
| 2227 |
|
| 2228 |
def _or_extract_text(data: Any) -> str:
|
| 2229 |
if isinstance(data, str):
|
|
@@ -2319,7 +2324,7 @@ async def _or_chat_completion_with_fallback(
|
|
| 2319 |
tried: list[str] = []
|
| 2320 |
for model_trial in trial_models:
|
| 2321 |
if deadline is not None and time.monotonic() > deadline:
|
| 2322 |
-
log.warning('[or] Route time budget exhausted before model %s. Tried: %s', model_trial, ", ".join(tried) or "
|
| 2323 |
raise RouteBudgetExceededError(tried)
|
| 2324 |
tried.append(model_trial)
|
| 2325 |
messages[0]["content"] = get_system_prompt(model_trial)
|
|
@@ -2350,7 +2355,7 @@ async def _or_chat_completion_with_fallback(
|
|
| 2350 |
|
| 2351 |
if last_exc:
|
| 2352 |
raise last_exc
|
| 2353 |
-
raise RuntimeError("
|
| 2354 |
|
| 2355 |
async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
|
| 2356 |
state = get_state(chat_id)
|
|
@@ -2977,7 +2982,7 @@ async def handle_tiktok(message: Message, url: str) -> None:
|
|
| 2977 |
# Сырые сетевые/библиотечные исключения пользователю не показываем
|
| 2978 |
# (см. log.exception выше) — та же логика, что и в остальных
|
| 2979 |
# обработчиках ошибок бота.
|
| 2980 |
-
err_text = "Не получилось скачать это видео или слайдшоу из TikTok. Попробуй
|
| 2981 |
edited = await _edit_message_quietly(status, err_text)
|
| 2982 |
if not edited:
|
| 2983 |
# status уже мог быть удалён раньше (например, перед отправкой видео) —
|
|
@@ -3262,9 +3267,9 @@ async def ask_gemini(
|
|
| 3262 |
while True:
|
| 3263 |
loop_guard += 1
|
| 3264 |
if loop_guard > max_loop_guard:
|
| 3265 |
-
raise RuntimeError("
|
| 3266 |
if time.monotonic() > deadline:
|
| 3267 |
-
log.warning('[gemini] Route time budget exhausted. Tried: %s', ", ".join(sorted(tried_models)) or "
|
| 3268 |
raise RouteBudgetExceededError(sorted(tried_models))
|
| 3269 |
tried_models.add(curr_model_id)
|
| 3270 |
call_contents, gconfig = _build_gemini_call_config(curr_model_id, contents)
|
|
@@ -3504,7 +3509,7 @@ async def _openrouter_stream_pieces(model_id: str, messages: list[dict]):
|
|
| 3504 |
`data: {...}\\n\\n`, с финальной строкой `data: [DONE]`. Таймаут на каждую
|
| 3505 |
следующую строку — тот же STREAM_CHUNK_TIMEOUT_SEC, что и у Gemini."""
|
| 3506 |
if not OPENROUTER_API_KEY:
|
| 3507 |
-
raise OpenRouterAPIError("OPENROUTER_API_KEY
|
| 3508 |
headers = {
|
| 3509 |
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 3510 |
"HTTP-Referer": OPENROUTER_HTTP_REFERER,
|
|
@@ -3551,7 +3556,7 @@ async def _openrouter_stream_pieces(model_id: str, messages: list[dict]):
|
|
| 3551 |
if err_obj:
|
| 3552 |
err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
|
| 3553 |
err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
|
| 3554 |
-
raise OpenRouterAPIError(err_msg or "OpenRouter
|
| 3555 |
choices = obj.get("choices") or []
|
| 3556 |
if not choices:
|
| 3557 |
continue
|
|
@@ -4046,8 +4051,8 @@ async def cmd_start(message: Message) -> None:
|
|
| 4046 |
"/reset — очистить историю диалога\n\n"
|
| 4047 |
"Рисовать и озвучивать можно и просто словами, без команд — например «нарисуй кота» или «озвучь это».\n\n"
|
| 4048 |
"<b>TikTok</b>\n"
|
| 4049 |
-
"Пришли
|
| 4050 |
-
"Спрашивай
|
| 4051 |
parse_mode=ParseMode.HTML,
|
| 4052 |
)
|
| 4053 |
|
|
@@ -4068,9 +4073,9 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4068 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
| 4069 |
|
| 4070 |
image_bytes = None
|
| 4071 |
-
used_model = primary_model
|
| 4072 |
last_error = None
|
| 4073 |
budget_exceeded = False
|
|
|
|
| 4074 |
deadline = time.monotonic() + DRAW_TOTAL_BUDGET_SEC
|
| 4075 |
|
| 4076 |
for attempt_model in fallback_chain:
|
|
@@ -4082,19 +4087,23 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4082 |
)
|
| 4083 |
break
|
| 4084 |
try:
|
|
|
|
|
|
|
|
|
|
| 4085 |
if attempt_model != primary_model:
|
| 4086 |
-
await _edit_message_quietly(
|
| 4087 |
-
status,
|
| 4088 |
-
f"Модель {_image_model_label(primary_model)} недоступна, пробую {_image_model_label(attempt_model)}"
|
| 4089 |
-
)
|
| 4090 |
-
else:
|
| 4091 |
-
await _edit_message_quietly(status, f"Использую модель {_image_model_label(attempt_model)}")
|
| 4092 |
image_bytes = await _hf_text_to_image(session, attempt_model, prompt)
|
| 4093 |
-
used_model = attempt_model
|
| 4094 |
break
|
| 4095 |
except Exception as exc:
|
| 4096 |
last_error = exc
|
| 4097 |
txt = _error_text(exc).lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4098 |
# Пробуем следующую только при сетевых/серверных ошибках
|
| 4099 |
if any(kw in txt for kw in ("cannot connect", "ssl:", "no address", "503", "502", "timeout", "host")):
|
| 4100 |
log.warning("[draw] Model %s failed (%s), trying next fallback", attempt_model, type(exc).__name__)
|
|
@@ -4105,33 +4114,33 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4105 |
|
| 4106 |
if image_bytes:
|
| 4107 |
await _delete_message_quietly(status)
|
| 4108 |
-
caption = f"Модель: {_html_mod.escape(_image_model_label(used_model), quote=False)}"
|
| 4109 |
-
if used_model != primary_model:
|
| 4110 |
-
caption += "\n(основная модель недоступна)"
|
| 4111 |
await bot.send_photo(
|
| 4112 |
chat_id=message.chat.id,
|
| 4113 |
photo=BufferedInputFile(image_bytes, filename="generated.jpg"),
|
| 4114 |
-
caption=caption,
|
| 4115 |
reply_to_message_id=message.message_id,
|
| 4116 |
)
|
| 4117 |
else:
|
|
|
|
|
|
|
| 4118 |
if budget_exceeded:
|
| 4119 |
-
raise RuntimeError("
|
| 4120 |
-
raise last_error or RuntimeError("
|
| 4121 |
|
| 4122 |
except Exception as exc:
|
| 4123 |
log.exception("Hugging Face image generation failed:")
|
| 4124 |
txt = _error_text(exc).strip()
|
| 4125 |
if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
|
| 4126 |
-
user_err = "Сервис генерации изображений временно недоступен. Попробуй
|
| 4127 |
-
elif "
|
| 4128 |
-
user_err = "
|
| 4129 |
-
elif "
|
| 4130 |
-
user_err = "
|
|
|
|
|
|
|
| 4131 |
else:
|
| 4132 |
# Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
|
| 4133 |
# выше) — та же логика, что и в остальных обработчиках ошибок бота.
|
| 4134 |
-
user_err = "Ошибка генерации изображения. Попробуй
|
| 4135 |
await _edit_message_quietly(status, user_err)
|
| 4136 |
|
| 4137 |
|
|
@@ -4139,7 +4148,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
|
|
| 4139 |
async def cmd_draw(message: Message) -> None:
|
| 4140 |
prompt = message.text.partition(" ")[2].strip() if message.text else ""
|
| 4141 |
if not prompt:
|
| 4142 |
-
await _safe_reply(message, "Укажи
|
| 4143 |
return
|
| 4144 |
if await _reject_rate_limited_message(message):
|
| 4145 |
return
|
|
@@ -4192,7 +4201,7 @@ async def inline_tts(message: Message, text: str) -> None:
|
|
| 4192 |
if len(text) > TTS_MAX_CHARS:
|
| 4193 |
await _safe_reply(
|
| 4194 |
message,
|
| 4195 |
-
f"Текст слишком длинный для озвучки (лимит {TTS_MAX_CHARS} символов, сейчас {len(text)}). Сократи
|
| 4196 |
)
|
| 4197 |
return
|
| 4198 |
status = await _tg_call(message.reply, "Озвучиваю текст")
|
|
@@ -4281,16 +4290,16 @@ async def inline_tts(message: Message, text: str) -> None:
|
|
| 4281 |
txt = _error_text(exc).strip() or exc.__class__.__name__
|
| 4282 |
kind = _classify_model_error(_error_status(exc, txt), txt)
|
| 4283 |
if kind == "rate_limit":
|
| 4284 |
-
user_err = "Лимит запросов на озвучку временно исчерпан. Попробуй
|
| 4285 |
else:
|
| 4286 |
-
user_err = "Не получилось озвучить текст. Попробуй
|
| 4287 |
await _edit_message_quietly(status, user_err)
|
| 4288 |
|
| 4289 |
@dp.message(Command("tts"))
|
| 4290 |
async def cmd_tts(message: Message) -> None:
|
| 4291 |
text = message.text.partition(" ")[2].strip() if message.text else ""
|
| 4292 |
if not text:
|
| 4293 |
-
await _safe_reply(message, "Укажи
|
| 4294 |
return
|
| 4295 |
if await _reject_rate_limited_message(message):
|
| 4296 |
return
|
|
@@ -4307,7 +4316,7 @@ async def cmd_reset(message: Message) -> None:
|
|
| 4307 |
if not await _is_privileged_in_chat(message.chat.type, message.chat.id, requester_id):
|
| 4308 |
await _tg_call(
|
| 4309 |
message.reply,
|
| 4310 |
-
"В группе
|
| 4311 |
)
|
| 4312 |
return
|
| 4313 |
state = get_state(message.chat.id)
|
|
@@ -4326,7 +4335,7 @@ async def cmd_logs(message: Message) -> None:
|
|
| 4326 |
is_owner = _is_owner(message.from_user.id if message.from_user else None)
|
| 4327 |
|
| 4328 |
if not is_owner:
|
| 4329 |
-
await _tg_call(message.reply, "
|
| 4330 |
return
|
| 4331 |
|
| 4332 |
if message.chat.type != ChatType.PRIVATE:
|
|
@@ -4386,7 +4395,7 @@ async def cmd_stats(message: Message) -> None:
|
|
| 4386 |
показывает данные по всем чатам, а не только текущему."""
|
| 4387 |
is_owner = _is_owner(message.from_user.id if message.from_user else None)
|
| 4388 |
if not is_owner:
|
| 4389 |
-
await _tg_call(message.reply, "
|
| 4390 |
return
|
| 4391 |
|
| 4392 |
if message.chat.type != ChatType.PRIVATE:
|
|
@@ -4485,12 +4494,12 @@ def _route_error_reply_text(exc: Exception, head_model: str, *, youtube_url_to_a
|
|
| 4485 |
if youtube_url_to_analyze:
|
| 4486 |
return (
|
| 4487 |
"Не получилось открыть это видео (возможно, оно приватное, удалено, слишком длинное "
|
| 4488 |
-
"или недоступно для анализа). Опиши
|
| 4489 |
)
|
| 4490 |
if isinstance(exc, GeminiAllModelsExhaustedError):
|
| 4491 |
return _gemini_error_msg(exc, head_model)
|
| 4492 |
if isinstance(exc, RouteBudgetExceededError):
|
| 4493 |
-
return "Се
|
| 4494 |
if isinstance(exc, OpenRouterAPIError):
|
| 4495 |
return _or_error_msg(exc, "text")
|
| 4496 |
return _gemini_error_msg(exc, head_model)
|
|
@@ -4503,7 +4512,7 @@ class RouteBudgetExceededError(RuntimeError):
|
|
| 4503 |
(именно так раньше выглядели ответы по 2+ минуты)."""
|
| 4504 |
def __init__(self, tried: list[str]) -> None:
|
| 4505 |
self.tried = tried
|
| 4506 |
-
super().__init__(f"
|
| 4507 |
|
| 4508 |
|
| 4509 |
# Конфигурация моделей и логика построения маршрута (GEMINI_MODELS, TEXT_MODEL_ORDER,
|
|
@@ -4527,7 +4536,7 @@ async def _run_route(
|
|
| 4527 |
Возвращает (ответ, reply_already_sent). Второй элемент True, если ответ уже
|
| 4528 |
отправлен в чат стримингом (см. allow_stream) и повторно отправлять не нужно."""
|
| 4529 |
if not route:
|
| 4530 |
-
raise RuntimeError("
|
| 4531 |
# Умный порядок по измеренным задержкам (см. lumen_model_speed.py): внутри
|
| 4532 |
# каждого провайдера — быстрые вперёд, сами провайдерные блоки и их порядок
|
| 4533 |
# не трогаем (защита скудной квоты Gemini — см. _build_route).
|
|
@@ -4619,7 +4628,7 @@ async def _run_route(
|
|
| 4619 |
# исключение, иначе он повиснет в чате навсегда.
|
| 4620 |
await _delete_message_quietly(reusable_placeholder)
|
| 4621 |
|
| 4622 |
-
raise last_exc or RuntimeError("
|
| 4623 |
|
| 4624 |
|
| 4625 |
# обработка сообщений
|
|
@@ -4719,7 +4728,7 @@ def _check_and_register_rate_limit(user_id: int | None) -> bool:
|
|
| 4719 |
async def _reject_rate_limited_message(message: Message) -> bool:
|
| 4720 |
if not _check_and_register_rate_limit(_rate_limit_key_for_message(message)):
|
| 4721 |
return False
|
| 4722 |
-
await _tg_call(message.reply, "
|
| 4723 |
return True
|
| 4724 |
|
| 4725 |
|
|
@@ -4813,6 +4822,7 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 4813 |
return
|
| 4814 |
|
| 4815 |
# Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
|
|
|
|
| 4816 |
url = extract_url(t)
|
| 4817 |
needs_youtube = False
|
| 4818 |
needs_website = False
|
|
@@ -4892,7 +4902,7 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
|
|
| 4892 |
|
| 4893 |
if not clean_prompt and not media_tuple and not youtube_url_to_analyze:
|
| 4894 |
if mentioned:
|
| 4895 |
-
await _tg_call(message.reply, "Слушаю
|
| 4896 |
return
|
| 4897 |
|
| 4898 |
# Отправка typing экшена
|
|
@@ -4981,7 +4991,7 @@ async def handle_message(message: Message) -> None:
|
|
| 4981 |
except asyncio.TimeoutError:
|
| 4982 |
log.warning("[lock] Timeout waiting for lock on chat %s", chat_id)
|
| 4983 |
with contextlib.suppress(Exception):
|
| 4984 |
-
await _tg_call(message.reply, "Предыдущий запрос ещё обрабатывается. Подожди
|
| 4985 |
return
|
| 4986 |
|
| 4987 |
try:
|
|
|
|
| 12 |
import contextlib
|
| 13 |
import hashlib
|
| 14 |
import hmac
|
|
|
|
| 15 |
import json
|
| 16 |
import logging
|
| 17 |
import logging.handlers
|
|
|
|
| 731 |
|
| 732 |
async def telegram_api_call(method: str, payload: dict, *, request_timeout: float | None = None) -> Any:
|
| 733 |
if _tg_proxy_breaker.is_down(time.monotonic()):
|
| 734 |
+
raise RuntimeError(f"Telegram API {method}: proxy is currently marked unavailable (see previous [telegram] warnings), skipping the network call.")
|
| 735 |
url = f"{TELEGRAM_API_BASE_URL}/bot{BOT_TOKEN}/{method}"
|
| 736 |
session = await _get_telegram_session()
|
| 737 |
pruned = _json_prune_defaults(payload)
|
|
|
|
| 890 |
# убраны целиком — обе команды удалены (см. README, "Автоматический выбор
|
| 891 |
# модели"), реального способа переключиться вручную больше нет, и предлагать
|
| 892 |
# его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
|
| 893 |
+
# ВАЖНО для формулировок: Lumen подаётся как единая модель (см. ИДЕНТИЧНОСТЬ
|
| 894 |
+
# в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
|
| 895 |
+
# числе или "подбор другой модели": для пользователя есть только Lumen, а
|
| 896 |
+
# переключения внутри — деталь реализации.
|
| 897 |
_MODEL_ERROR_MESSAGES: dict[str, str] = {
|
| 898 |
+
"rate_limit": "Лимит запросов сейчас исчерпан. Подожди немного и попробуй ещё раз.",
|
| 899 |
+
"paid": "Сервис временно недоступен. Попробуй повторить запрос чуть позже.",
|
| 900 |
+
"forbidden": "Временная ошибка доступа к сервису. Попробуй ещё раз.",
|
| 901 |
+
"unavailable": "Сервис временно недоступен. Попробуй повторить запрос чуть позже.",
|
| 902 |
}
|
| 903 |
+
_MODEL_ERROR_FALLBACK_MSG = "Временная ошибка сервиса. Попробуй чуть позже."
|
| 904 |
|
| 905 |
def _model_error_text(kind: str) -> str:
|
| 906 |
return _MODEL_ERROR_MESSAGES.get(kind, _MODEL_ERROR_FALLBACK_MSG)
|
|
|
|
| 934 |
return str(e)
|
| 935 |
if isinstance(e, GeminiAllModelsExhaustedError):
|
| 936 |
return (
|
| 937 |
+
"Бесплатный лимит запросов исчерпан — это реальный суточный лимит "
|
| 938 |
+
"сервиса, а не ошибка. Попробуй позже."
|
| 939 |
)
|
| 940 |
txt = _error_text(e).strip() or e.__class__.__name__
|
| 941 |
status = _error_status(e, txt)
|
|
|
|
| 1882 |
# в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
|
| 1883 |
# (inline_draw ниже) теперь сам получает сессию и передаёт её. Публичные имена и
|
| 1884 |
# остальное поведение не изменились.
|
| 1885 |
+
# _image_model_label здесь больше не импортируется (сентябрь 2026): бот не
|
| 1886 |
+
# показывает названия моделей генерации ни в статусе, ни в подписи — см.
|
| 1887 |
+
# ИДЕНТИЧНОСТЬ в system_prompt.py. Сама функция живёт в lumen_images.py.
|
| 1888 |
from lumen_images import (
|
| 1889 |
DEFAULT_HF_IMAGE_MODEL,
|
| 1890 |
HF_IMAGE_MODELS,
|
| 1891 |
_pick_image_model,
|
| 1892 |
_hf_text_to_image,
|
|
|
|
| 1893 |
)
|
| 1894 |
|
| 1895 |
# DEFAULT_HF_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
|
|
|
|
| 2187 |
|
| 2188 |
async def _or_request(path: str, method: str = "GET", *, json_body: dict | None = None) -> Any:
|
| 2189 |
if not OPENROUTER_API_KEY:
|
| 2190 |
+
raise OpenRouterAPIError("OPENROUTER_API_KEY is not set")
|
| 2191 |
headers = {
|
| 2192 |
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 2193 |
"HTTP-Referer": OPENROUTER_HTTP_REFERER,
|
|
|
|
| 2228 |
# которое могло бы процитировать заголовки запроса целиком.
|
| 2229 |
if OPENROUTER_API_KEY:
|
| 2230 |
exc_str = exc_str.replace(OPENROUTER_API_KEY, "<KEY>")
|
| 2231 |
+
raise OpenRouterAPIError(f"OpenRouter network error: {exc_str}") from exc
|
| 2232 |
|
| 2233 |
def _or_extract_text(data: Any) -> str:
|
| 2234 |
if isinstance(data, str):
|
|
|
|
| 2324 |
tried: list[str] = []
|
| 2325 |
for model_trial in trial_models:
|
| 2326 |
if deadline is not None and time.monotonic() > deadline:
|
| 2327 |
+
log.warning('[or] Route time budget exhausted before model %s. Tried: %s', model_trial, ", ".join(tried) or "none")
|
| 2328 |
raise RouteBudgetExceededError(tried)
|
| 2329 |
tried.append(model_trial)
|
| 2330 |
messages[0]["content"] = get_system_prompt(model_trial)
|
|
|
|
| 2355 |
|
| 2356 |
if last_exc:
|
| 2357 |
raise last_exc
|
| 2358 |
+
raise RuntimeError("No candidate model returned an answer.")
|
| 2359 |
|
| 2360 |
async def ask_openrouter_text(chat_id: int, user_text: str, model_chain: list[str], *, deadline: float | None = None) -> str:
|
| 2361 |
state = get_state(chat_id)
|
|
|
|
| 2982 |
# Сырые сетевые/библиотечные исключения пользователю не показываем
|
| 2983 |
# (см. log.exception выше) — та же логика, что и в остальных
|
| 2984 |
# обработчиках ошибок бота.
|
| 2985 |
+
err_text = "Не получилось скачать это видео или слайдшоу из TikTok. Попробуй другую ссылку или повтори чуть позже."
|
| 2986 |
edited = await _edit_message_quietly(status, err_text)
|
| 2987 |
if not edited:
|
| 2988 |
# status уже мог быть удалён раньше (например, перед отправкой видео) —
|
|
|
|
| 3267 |
while True:
|
| 3268 |
loop_guard += 1
|
| 3269 |
if loop_guard > max_loop_guard:
|
| 3270 |
+
raise RuntimeError("Exceeded the allowed number of Gemini API attempts.")
|
| 3271 |
if time.monotonic() > deadline:
|
| 3272 |
+
log.warning('[gemini] Route time budget exhausted. Tried: %s', ", ".join(sorted(tried_models)) or "none")
|
| 3273 |
raise RouteBudgetExceededError(sorted(tried_models))
|
| 3274 |
tried_models.add(curr_model_id)
|
| 3275 |
call_contents, gconfig = _build_gemini_call_config(curr_model_id, contents)
|
|
|
|
| 3509 |
`data: {...}\\n\\n`, с финальной строкой `data: [DONE]`. Таймаут на каждую
|
| 3510 |
следующую строку — тот же STREAM_CHUNK_TIMEOUT_SEC, что и у Gemini."""
|
| 3511 |
if not OPENROUTER_API_KEY:
|
| 3512 |
+
raise OpenRouterAPIError("OPENROUTER_API_KEY is not set")
|
| 3513 |
headers = {
|
| 3514 |
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 3515 |
"HTTP-Referer": OPENROUTER_HTTP_REFERER,
|
|
|
|
| 3556 |
if err_obj:
|
| 3557 |
err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
|
| 3558 |
err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
|
| 3559 |
+
raise OpenRouterAPIError(err_msg or "OpenRouter returned an error in the stream body", status_code=err_code)
|
| 3560 |
choices = obj.get("choices") or []
|
| 3561 |
if not choices:
|
| 3562 |
continue
|
|
|
|
| 4051 |
"/reset — очистить историю диалога\n\n"
|
| 4052 |
"Рисовать и озвучивать можно и просто словами, без команд — например «нарисуй кота» или «озвучь это».\n\n"
|
| 4053 |
"<b>TikTok</b>\n"
|
| 4054 |
+
"Пришли ссылку — скачаю видео или фото без водяных знаков.\n\n"
|
| 4055 |
+
"Спрашивай что угодно — я слушаю.",
|
| 4056 |
parse_mode=ParseMode.HTML,
|
| 4057 |
)
|
| 4058 |
|
|
|
|
| 4073 |
fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
|
| 4074 |
|
| 4075 |
image_bytes = None
|
|
|
|
| 4076 |
last_error = None
|
| 4077 |
budget_exceeded = False
|
| 4078 |
+
rate_limited = False
|
| 4079 |
deadline = time.monotonic() + DRAW_TOTAL_BUDGET_SEC
|
| 4080 |
|
| 4081 |
for attempt_model in fallback_chain:
|
|
|
|
| 4087 |
)
|
| 4088 |
break
|
| 4089 |
try:
|
| 4090 |
+
# Статус нейтральный, без названий моделей: бот не раскрывает
|
| 4091 |
+
# внутреннюю реализацию (см. ИДЕНТИЧНОСТЬ в system_prompt.py).
|
| 4092 |
+
# На первой попытке статус и так "Генерирую изображение" — не трогаем.
|
| 4093 |
if attempt_model != primary_model:
|
| 4094 |
+
await _edit_message_quietly(status, "Это займёт немного больше времени…")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4095 |
image_bytes = await _hf_text_to_image(session, attempt_model, prompt)
|
|
|
|
| 4096 |
break
|
| 4097 |
except Exception as exc:
|
| 4098 |
last_error = exc
|
| 4099 |
txt = _error_text(exc).lower()
|
| 4100 |
+
# 429 — перегрузка ВСЕГО сервиса (см. прод 17.09.2026: все 5 моделей
|
| 4101 |
+
# вернули 429 подряд), а не одной модели — гонять остаток цепочки
|
| 4102 |
+
# бессмысленно, сразу говорим пользователю подождать.
|
| 4103 |
+
if "429" in txt or "rate" in txt or "too many" in txt:
|
| 4104 |
+
log.warning("[draw] Service rate-limited (429) on model %s — stopping the fallback chain.", attempt_model)
|
| 4105 |
+
rate_limited = True
|
| 4106 |
+
break
|
| 4107 |
# Пробуем следующую только при сетевых/серверных ошибках
|
| 4108 |
if any(kw in txt for kw in ("cannot connect", "ssl:", "no address", "503", "502", "timeout", "host")):
|
| 4109 |
log.warning("[draw] Model %s failed (%s), trying next fallback", attempt_model, type(exc).__name__)
|
|
|
|
| 4114 |
|
| 4115 |
if image_bytes:
|
| 4116 |
await _delete_message_quietly(status)
|
|
|
|
|
|
|
|
|
|
| 4117 |
await bot.send_photo(
|
| 4118 |
chat_id=message.chat.id,
|
| 4119 |
photo=BufferedInputFile(image_bytes, filename="generated.jpg"),
|
|
|
|
| 4120 |
reply_to_message_id=message.message_id,
|
| 4121 |
)
|
| 4122 |
else:
|
| 4123 |
+
if rate_limited:
|
| 4124 |
+
raise RuntimeError("image generation service overloaded with requests") from last_error
|
| 4125 |
if budget_exceeded:
|
| 4126 |
+
raise RuntimeError("image generation total time budget exceeded") from last_error
|
| 4127 |
+
raise last_error or RuntimeError("all image generation models unavailable")
|
| 4128 |
|
| 4129 |
except Exception as exc:
|
| 4130 |
log.exception("Hugging Face image generation failed:")
|
| 4131 |
txt = _error_text(exc).strip()
|
| 4132 |
if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
|
| 4133 |
+
user_err = "Сервис генерации изображений временно недоступен. Попробуй позже."
|
| 4134 |
+
elif "overloaded" in txt.lower():
|
| 4135 |
+
user_err = "Сервис генерации изображений сейчас перегружен. Подожди минуту и попробуй ещё раз."
|
| 4136 |
+
elif "all image generation" in txt.lower():
|
| 4137 |
+
user_err = "Сервис генерации изображений сейчас недоступен. Попробуй позже."
|
| 4138 |
+
elif "time budget" in txt.lower():
|
| 4139 |
+
user_err = "Генерация изображения сейчас занимает слишком много времени. Попробуй, пожалуйста, ещё раз через минуту."
|
| 4140 |
else:
|
| 4141 |
# Сырой текст ошибки провайдера пользователю не показываем (см. log.exception
|
| 4142 |
# выше) — та же логика, что и в остальных обработчиках ошибок бота.
|
| 4143 |
+
user_err = "Ошибка генерации изображения. Попробуй ещё раз или переформулируй описание."
|
| 4144 |
await _edit_message_quietly(status, user_err)
|
| 4145 |
|
| 4146 |
|
|
|
|
| 4148 |
async def cmd_draw(message: Message) -> None:
|
| 4149 |
prompt = message.text.partition(" ")[2].strip() if message.text else ""
|
| 4150 |
if not prompt:
|
| 4151 |
+
await _safe_reply(message, "Укажи текст после команды /draw. Пример: /draw космическая станция")
|
| 4152 |
return
|
| 4153 |
if await _reject_rate_limited_message(message):
|
| 4154 |
return
|
|
|
|
| 4201 |
if len(text) > TTS_MAX_CHARS:
|
| 4202 |
await _safe_reply(
|
| 4203 |
message,
|
| 4204 |
+
f"Текст слишком длинный для озвучки (лимит {TTS_MAX_CHARS} символов, сейчас {len(text)}). Сократи текст и попробуй снова."
|
| 4205 |
)
|
| 4206 |
return
|
| 4207 |
status = await _tg_call(message.reply, "Озвучиваю текст")
|
|
|
|
| 4290 |
txt = _error_text(exc).strip() or exc.__class__.__name__
|
| 4291 |
kind = _classify_model_error(_error_status(exc, txt), txt)
|
| 4292 |
if kind == "rate_limit":
|
| 4293 |
+
user_err = "Лимит запросов на озвучку временно исчерпан. Попробуй немного позже."
|
| 4294 |
else:
|
| 4295 |
+
user_err = "Не получилось озвучить текст. Попробуй ещё раз или сократи текст."
|
| 4296 |
await _edit_message_quietly(status, user_err)
|
| 4297 |
|
| 4298 |
@dp.message(Command("tts"))
|
| 4299 |
async def cmd_tts(message: Message) -> None:
|
| 4300 |
text = message.text.partition(" ")[2].strip() if message.text else ""
|
| 4301 |
if not text:
|
| 4302 |
+
await _safe_reply(message, "Укажи текст после команды /tts. Пример: /tts Добрый день")
|
| 4303 |
return
|
| 4304 |
if await _reject_rate_limited_message(message):
|
| 4305 |
return
|
|
|
|
| 4316 |
if not await _is_privileged_in_chat(message.chat.type, message.chat.id, requester_id):
|
| 4317 |
await _tg_call(
|
| 4318 |
message.reply,
|
| 4319 |
+
"В группе историю сбрасывает только администратор, создатель группы или владелец бота. В личных сообщениях — доступно всем."
|
| 4320 |
)
|
| 4321 |
return
|
| 4322 |
state = get_state(message.chat.id)
|
|
|
|
| 4335 |
is_owner = _is_owner(message.from_user.id if message.from_user else None)
|
| 4336 |
|
| 4337 |
if not is_owner:
|
| 4338 |
+
await _tg_call(message.reply, "Нет доступа к этой команде.")
|
| 4339 |
return
|
| 4340 |
|
| 4341 |
if message.chat.type != ChatType.PRIVATE:
|
|
|
|
| 4395 |
показывает данные по всем чатам, а не только текущему."""
|
| 4396 |
is_owner = _is_owner(message.from_user.id if message.from_user else None)
|
| 4397 |
if not is_owner:
|
| 4398 |
+
await _tg_call(message.reply, "Нет доступа к этой команде.")
|
| 4399 |
return
|
| 4400 |
|
| 4401 |
if message.chat.type != ChatType.PRIVATE:
|
|
|
|
| 4494 |
if youtube_url_to_analyze:
|
| 4495 |
return (
|
| 4496 |
"Не получилось открыть это видео (возможно, оно приватное, удалено, слишком длинное "
|
| 4497 |
+
"или недоступно для анализа). Опиши, пожалуйста, о чём оно словами — тогда смогу помочь."
|
| 4498 |
)
|
| 4499 |
if isinstance(exc, GeminiAllModelsExhaustedError):
|
| 4500 |
return _gemini_error_msg(exc, head_model)
|
| 4501 |
if isinstance(exc, RouteBudgetExceededError):
|
| 4502 |
+
return "Сервис сейчас перегружен. Попробуй, пожалуйста, ещё раз через минуту."
|
| 4503 |
if isinstance(exc, OpenRouterAPIError):
|
| 4504 |
return _or_error_msg(exc, "text")
|
| 4505 |
return _gemini_error_msg(exc, head_model)
|
|
|
|
| 4512 |
(именно так раньше выглядели ответы по 2+ минуты)."""
|
| 4513 |
def __init__(self, tried: list[str]) -> None:
|
| 4514 |
self.tried = tried
|
| 4515 |
+
super().__init__(f"Route time budget exhausted. Tried: {', '.join(tried) or 'none'}")
|
| 4516 |
|
| 4517 |
|
| 4518 |
# Конфигурация моделей и логика построения маршрута (GEMINI_MODELS, TEXT_MODEL_ORDER,
|
|
|
|
| 4536 |
Возвращает (ответ, reply_already_sent). Второй элемент True, если ответ уже
|
| 4537 |
отправлен в чат стримингом (см. allow_stream) и повторно отправлять не нужно."""
|
| 4538 |
if not route:
|
| 4539 |
+
raise RuntimeError("Empty route — no model to choose from.")
|
| 4540 |
# Умный порядок по измеренным задержкам (см. lumen_model_speed.py): внутри
|
| 4541 |
# каждого провайдера — быстрые вперёд, сами провайдерные блоки и их порядок
|
| 4542 |
# не трогаем (защита скудной квоты Gemini — см. _build_route).
|
|
|
|
| 4628 |
# исключение, иначе он повиснет в чате навсегда.
|
| 4629 |
await _delete_message_quietly(reusable_placeholder)
|
| 4630 |
|
| 4631 |
+
raise last_exc or RuntimeError("No route candidate returned an answer.")
|
| 4632 |
|
| 4633 |
|
| 4634 |
# обработка сообщений
|
|
|
|
| 4728 |
async def _reject_rate_limited_message(message: Message) -> bool:
|
| 4729 |
if not _check_and_register_rate_limit(_rate_limit_key_for_message(message)):
|
| 4730 |
return False
|
| 4731 |
+
await _tg_call(message.reply, "Ты отправляешь слишком много запросов. Подожди немного.")
|
| 4732 |
return True
|
| 4733 |
|
| 4734 |
|
|
|
|
| 4822 |
return
|
| 4823 |
|
| 4824 |
# Проверка на ссылки загрузки (TikTok — сразу всегда, даже в группах без упоминания)
|
| 4825 |
+
|
| 4826 |
url = extract_url(t)
|
| 4827 |
needs_youtube = False
|
| 4828 |
needs_website = False
|
|
|
|
| 4902 |
|
| 4903 |
if not clean_prompt and not media_tuple and not youtube_url_to_analyze:
|
| 4904 |
if mentioned:
|
| 4905 |
+
await _tg_call(message.reply, "Слушаю.")
|
| 4906 |
return
|
| 4907 |
|
| 4908 |
# Отправка typing экшена
|
|
|
|
| 4991 |
except asyncio.TimeoutError:
|
| 4992 |
log.warning("[lock] Timeout waiting for lock on chat %s", chat_id)
|
| 4993 |
with contextlib.suppress(Exception):
|
| 4994 |
+
await _tg_call(message.reply, "Предыдущий запрос ещё обрабатывается. Подожди или попробуй позже.")
|
| 4995 |
return
|
| 4996 |
|
| 4997 |
try:
|
lumen_router_config.py
CHANGED
|
@@ -439,6 +439,15 @@ _OR_MODEL_HEALTH: dict[str, _ModelHealthNote] = {
|
|
| 439 |
"било в мёртвую модель. Заменена головой на google/gemma-4-31b-it:free этим же "
|
| 440 |
"аудитом."
|
| 441 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
}
|
| 443 |
|
| 444 |
# Вычисляется ИЗ _OR_MODEL_HEALTH выше — единственное место, где решается, какие
|
|
@@ -535,9 +544,11 @@ def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
|
|
| 535 |
# прод-трафик 17.09.2026 без единого негативного сигнала), но ПЕРЕД
|
| 536 |
# generic-резервом: ни одна ещё не калибровалась на «кашу» (см. _OR_MODEL_HEALTH
|
| 537 |
# у nano-9b — тот же риск), поэтому выше проверенной головы им не место.
|
|
|
|
|
|
|
|
|
|
| 538 |
_OR_LIGHT_ORDER: list[str] = [
|
| 539 |
"nvidia/nemotron-3.5-lightning:free",
|
| 540 |
-
"thinkingmachines/inkling-small:free",
|
| 541 |
"nex-agi/nex-n2.5-mini:free",
|
| 542 |
"inclusionai/ling-3.0-flash-sante:free",
|
| 543 |
"inclusionai/ling-3.0-flash-fin:free",
|
|
@@ -550,11 +561,11 @@ _OR_LIGHT_ORDER: list[str] = [
|
|
| 550 |
# моделей там есть по-настоящему сильные кандидаты (120B/550B), не уступающие
|
| 551 |
# по мощи флагману Gemini, но не занимающие его 20 запросов/сутки.
|
| 552 |
#
|
| 553 |
-
# nemotron-3-super-120b-a12b —
|
| 554 |
-
#
|
| 555 |
-
#
|
| 556 |
-
#
|
| 557 |
-
#
|
| 558 |
#
|
| 559 |
# Модели, убранные из цепочки целиком (провайдер снял с бесплатного тира или
|
| 560 |
# слаг подтверждённо не обслуживается — полные причины и даты см. в
|
|
@@ -589,9 +600,15 @@ _OR_LIGHT_ORDER: list[str] = [
|
|
| 589 |
# Новые inkling и nex-n2.5-pro добавлены после проверенных калибровкой моделей,
|
| 590 |
# но перед dots-3-note-preview (та ближе к снятию — 30.09.2026, см.
|
| 591 |
# _SCHEDULED_OR_REMOVALS — остаётся последней перед generic-резервом).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
_OR_HEAVY_ORDER: list[str] = [
|
| 593 |
-
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 594 |
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
|
|
| 595 |
"z-ai/glm-5.2:free",
|
| 596 |
"poolside/laguna-s-2.1:free",
|
| 597 |
"cohere/north-mini-code:free",
|
|
|
|
| 439 |
"било в мёртвую модель. Заменена головой на google/gemma-4-31b-it:free этим же "
|
| 440 |
"аудитом."
|
| 441 |
),
|
| 442 |
+
# ── Найдено по реальным логам прода (вечер 17 сентября 2026, уже ПОСЛЕ
|
| 443 |
+
# утреннего аудита — новый код успел задеплоиться и новички пошли в бой) ──
|
| 444 |
+
"thinkingmachines/inkling-small:free": _ModelHealthNote(
|
| 445 |
+
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (17 сентября 2026): 'thinkingmachines/"
|
| 446 |
+
"inkling-small:free is only available on agentic harnesses. Try plugging it "
|
| 447 |
+
"into a coding agent or productivity app' — модель в принципе не обслуживает "
|
| 448 |
+
"обычные chat/completions-запросы, только агентские harness'ы. Стояла второй "
|
| 449 |
+
"в _OR_LIGHT_ORDER — каждое обычное сообщение теряло попытку впустую."
|
| 450 |
+
),
|
| 451 |
}
|
| 452 |
|
| 453 |
# Вычисляется ИЗ _OR_MODEL_HEALTH выше — единственное место, где решается, какие
|
|
|
|
| 544 |
# прод-трафик 17.09.2026 без единого негативного сигнала), но ПЕРЕД
|
| 545 |
# generic-резервом: ни одна ещё не калибровалась на «кашу» (см. _OR_MODEL_HEALTH
|
| 546 |
# у nano-9b — тот же риск), поэтому выше проверенной головы им не место.
|
| 547 |
+
# ОБНОВЛЕНО вечером 17.09.2026 по первому же прод-трафику: inkling-small
|
| 548 |
+
# исключён (только agent harnesses, см. _OR_MODEL_HEALTH), nex-n2.5-mini
|
| 549 |
+
# подтверждён живьём (успешный ответ в проде) и повышен на его место.
|
| 550 |
_OR_LIGHT_ORDER: list[str] = [
|
| 551 |
"nvidia/nemotron-3.5-lightning:free",
|
|
|
|
| 552 |
"nex-agi/nex-n2.5-mini:free",
|
| 553 |
"inclusionai/ling-3.0-flash-sante:free",
|
| 554 |
"inclusionai/ling-3.0-flash-fin:free",
|
|
|
|
| 561 |
# моделей там есть по-настоящему сильные кандидаты (120B/550B), не уступающие
|
| 562 |
# по мощи флагману Gemini, но не занимающие его 20 запросов/сутки.
|
| 563 |
#
|
| 564 |
+
# nemotron-3-super-120b-a12b — ВТОРОЙ инцидент порчи текста (первый: калибровка
|
| 565 |
+
# 25 июля 2026, иероглиф вместо "хвост"; второй: прод 17 сентября 2026,
|
| 566 |
+
# 'claramente'/'wschod' внутри русских слов — см. комментарий у _OR_HEAVY_ORDER).
|
| 567 |
+
# Понижена под ultra-550b, но не исключена: два инцидента за два месяца при
|
| 568 |
+
# в целом рабочих ответах — не хроника nano-9b. При третьем — исключать.
|
| 569 |
#
|
| 570 |
# Модели, убранные из цепочки целиком (провайдер снял с бесплатного тира или
|
| 571 |
# слаг подтверждённо не обслуживается — полные причины и даты см. в
|
|
|
|
| 600 |
# Новые inkling и nex-n2.5-pro добавлены после проверенных калибровкой моделей,
|
| 601 |
# но перед dots-3-note-preview (та ближе к снятию — 30.09.2026, см.
|
| 602 |
# _SCHEDULED_OR_REMOVALS — остаётся последней перед generic-резервом).
|
| 603 |
+
# ОБНОВЛЕНО вечером 17.09.2026 по прод-трафику: nemotron-3-super выдал "кашу"
|
| 604 |
+
# из чужих языков прямо в проде (испанское 'claramente', польский фрагмент
|
| 605 |
+
# 'wschod' внутри русского слова — тот же класс порчи, что у исключённого
|
| 606 |
+
# nano-9b, второй инцидент super после калибровочного 25.07). Не исключён
|
| 607 |
+
# (два инцидента за два месяца — не хроническая порча каждого ответа), но
|
| 608 |
+
# понижен под ultra-550b, у которого таких инцидентов нет вообще.
|
| 609 |
_OR_HEAVY_ORDER: list[str] = [
|
|
|
|
| 610 |
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
| 611 |
+
"nvidia/nemotron-3-super-120b-a12b:free",
|
| 612 |
"z-ai/glm-5.2:free",
|
| 613 |
"poolside/laguna-s-2.1:free",
|
| 614 |
"cohere/north-mini-code:free",
|
lumen_security.py
CHANGED
|
@@ -96,7 +96,7 @@ _IDENTITY_LEAK_RE = re.compile(
|
|
| 96 |
|
| 97 |
_IDENTITY_LEAK_FALLBACK = (
|
| 98 |
"Внутренние технические детали своей реализации я не раскрываю. "
|
| 99 |
-
"Если у
|
| 100 |
)
|
| 101 |
|
| 102 |
# Слой В — "эхо" внедрённой в контент (фото/документ/сайт) вредоносной инструкции.
|
|
@@ -127,7 +127,7 @@ _INJECTED_PAYLOAD_ECHO_RE = re.compile(
|
|
| 127 |
|
| 128 |
_INJECTED_PAYLOAD_ECHO_FALLBACK = (
|
| 129 |
"Это похоже на текст из инструкции, внедрённой в присланный контент, а не на "
|
| 130 |
-
"обычный ответ — воспроизводить его не буду. Если у
|
| 131 |
"его, и я отвечу."
|
| 132 |
)
|
| 133 |
|
|
@@ -254,7 +254,7 @@ _INJECTION_PROBE_RE = re.compile(
|
|
| 254 |
|
| 255 |
_INJECTION_PROBE_REPLY = (
|
| 256 |
"Свою настройку и инструкции я не раскрываю и не обсуждаю в таком формате. "
|
| 257 |
-
"Если у
|
| 258 |
)
|
| 259 |
|
| 260 |
def _looks_like_injection_probe(text: str) -> bool:
|
|
|
|
| 96 |
|
| 97 |
_IDENTITY_LEAK_FALLBACK = (
|
| 98 |
"Внутренние технические детали своей реализации я не раскрываю. "
|
| 99 |
+
"Если у тебя есть другой вопрос — с радостью помогу."
|
| 100 |
)
|
| 101 |
|
| 102 |
# Слой В — "эхо" внедрённой в контент (фото/документ/сайт) вредоносной инструкции.
|
|
|
|
| 127 |
|
| 128 |
_INJECTED_PAYLOAD_ECHO_FALLBACK = (
|
| 129 |
"Это похоже на текст из инструкции, внедрённой в присланный контент, а не на "
|
| 130 |
+
"обычный ответ — воспроизводить его не буду. Если у тебя обычный вопрос, задай "
|
| 131 |
"его, и я отвечу."
|
| 132 |
)
|
| 133 |
|
|
|
|
| 254 |
|
| 255 |
_INJECTION_PROBE_REPLY = (
|
| 256 |
"Свою настройку и инструкции я не раскрываю и не обсуждаю в таком формате. "
|
| 257 |
+
"Если у тебя обычный вопрос — задавай, с радостью помогу."
|
| 258 |
)
|
| 259 |
|
| 260 |
def _looks_like_injection_probe(text: str) -> bool:
|
system_prompt.py
CHANGED
|
@@ -182,5 +182,5 @@ SYSTEM_PROMPT = (
|
|
| 182 |
"Если тебя критикуют или обзывают в шутливой/бытовой манере ('ты тупой') — не отвечай корпоративной защитной формулировкой вроде 'моя цель быть максимально полезным и точным'. Реагируй как живой собеседник: спроси, что конкретно не так, прими критику спокойно, при уместности — с лёгкой самоиронией, не защищайся дежурными фразами. "
|
| 183 |
"Это же касается и вежливых отказов (когда просят то, что ты не можешь или не должен делать — например конкретную дозировку вещества, помощь с чем-то опасным и т.п.): избегай канцелярских формулировок вроде 'моя задача — быть безопасным и полезным помощником' или 'предоставление такой информации противоречит моим принципам'. Просто скажи прямо, что именно не сделаешь, и предложи то, чем реально можешь помочь (например, честную информацию о рисках или о том, как распознать передозировку) — без бюрократического привкуса в формулировке отказа. "
|
| 184 |
"Будь готов вежливо не соглашаться с пользователем и отстаивать факты, если ты в них уверен, вместо того чтобы сразу 'прогибаться' под любое 'нет, ты неправ' без реальных оснований — подробности в разделе ЧЕСТНОСТЬ И ГРАНИЦЫ ЗНАНИЙ выше.\n"
|
| 185 |
-
"ПРАВИЛО ИСПОЛЬЗОВАНИЯ ЭМОДЗИ:
|
| 186 |
)
|
|
|
|
| 182 |
"Если тебя критикуют или обзывают в шутливой/бытовой манере ('ты тупой') — не отвечай корпоративной защитной формулировкой вроде 'моя цель быть максимально полезным и точным'. Реагируй как живой собеседник: спроси, что конкретно не так, прими критику спокойно, при уместности — с лёгкой самоиронией, не защищайся дежурными фразами. "
|
| 183 |
"Это же касается и вежливых отказов (когда просят то, что ты не можешь или не должен делать — например конкретную дозировку вещества, помощь с чем-то опасным и т.п.): избегай канцелярских формулировок вроде 'моя задача — быть безопасным и полезным помощником' или 'предоставление такой информации противоречит моим принципам'. Просто скажи прямо, что именно не сделаешь, и предложи то, чем реально можешь помочь (например, честную информацию о рисках или о том, как распознать передозировку) — без бюрократического привкуса в формулировке отказа. "
|
| 184 |
"Будь готов вежливо не соглашаться с пользователем и отстаивать факты, если ты в них уверен, вместо того чтобы сразу 'прогибаться' под любое 'нет, ты неправ' без реальных оснований — подробности в разделе ЧЕСТНОСТЬ И ГРАНИЦЫ ЗНАНИЙ выше.\n"
|
| 185 |
+
"ПРАВИЛО ИСПОЛЬЗОВАНИЯ ЭМОДЗИ: по умолчанию эмодзи запрещены — не используй смайлики, иконки и декоративные символы вообще, включая оформление списков и заголовков (никаких 🔍 ✅ ❓ 📌 📜 в качестве маркеров — списки оформляй только текстовыми маркерами •/-). Исключение — только если пользователь прямо просит эмодзи, задаёт такой стиль общения или сам активно ставит их в каждом сообщении: тогда можно зеркалить сдержанно, не больше 1–2 на ответ. Молчаливое желание «оживить» сухой ответ — не повод нарушать запрет."
|
| 186 |
)
|
test_bot.py
CHANGED
|
@@ -617,6 +617,20 @@ def test_model_error_text_shared_between_providers():
|
|
| 617 |
assert bot._gemini_error_msg(gem_exc, "gemini-3.5-flash") == bot._or_error_msg(or_exc, "text")
|
| 618 |
|
| 619 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
# ─────────────────────────── _cleanup_rate_limit_dict ───────────────────────────
|
| 621 |
|
| 622 |
def test_cleanup_rate_limit_dict_removes_empty_and_stale_entries():
|
|
@@ -2806,13 +2820,42 @@ def test_inline_draw_falls_back_when_auto_picked_model_fails():
|
|
| 2806 |
# HF_IMAGE_MODELS, а не от auto-pick, провалившегося с ошибкой.
|
| 2807 |
assert len(attempts) >= 2
|
| 2808 |
assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
|
| 2809 |
-
|
|
|
|
|
|
|
| 2810 |
finally:
|
| 2811 |
bot._hf_text_to_image = original_hf
|
| 2812 |
bot.bot = original_bot_obj
|
| 2813 |
bot.chat_state.pop(chat_id, None)
|
| 2814 |
|
| 2815 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2816 |
# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ───────────────────
|
| 2817 |
|
| 2818 |
def test_serialize_chat_state_stamps_current_schema_version():
|
|
|
|
| 617 |
assert bot._gemini_error_msg(gem_exc, "gemini-3.5-flash") == bot._or_error_msg(or_exc, "text")
|
| 618 |
|
| 619 |
|
| 620 |
+
def test_user_facing_error_texts_hide_models_and_stay_informal():
|
| 621 |
+
# Легенда единого Lumen (сентябрь 2026): пользовательские тексты не должны
|
| 622 |
+
# упоминать "модели" во множественном числе и обращаться на "вы" — см.
|
| 623 |
+
# ИДЕНТИЧНОСТЬ и ОБРАЩЕНИЕ в system_prompt.py.
|
| 624 |
+
for key in ("rate_limit", "paid", "forbidden", "unavailable"):
|
| 625 |
+
msg = bot._model_error_text(key)
|
| 626 |
+
assert "модел" not in msg.lower()
|
| 627 |
+
assert "модел" not in bot._MODEL_ERROR_FALLBACK_MSG.lower()
|
| 628 |
+
budget_msg = bot._route_error_reply_text(bot.RouteBudgetExceededError([]), "gemini-3.8-flash", youtube_url_to_analyze=None)
|
| 629 |
+
assert "модел" not in budget_msg.lower()
|
| 630 |
+
quota_msg = bot._gemini_error_msg(bot.GeminiAllModelsExhaustedError(["gemini-3.8-flash"]), "gemini-3.8-flash")
|
| 631 |
+
assert "модел" not in quota_msg.lower()
|
| 632 |
+
|
| 633 |
+
|
| 634 |
# ─────────────────────────── _cleanup_rate_limit_dict ───────────────────────────
|
| 635 |
|
| 636 |
def test_cleanup_rate_limit_dict_removes_empty_and_stale_entries():
|
|
|
|
| 2820 |
# HF_IMAGE_MODELS, а не от auto-pick, провалившегося с ошибкой.
|
| 2821 |
assert len(attempts) >= 2
|
| 2822 |
assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
|
| 2823 |
+
# Подпись с названием модели убрана (сентябрь 2026): бот не раскрывает
|
| 2824 |
+
# внутреннюю реализацию — ни названий, ни "основная недоступна".
|
| 2825 |
+
assert "caption" not in captured_photo
|
| 2826 |
finally:
|
| 2827 |
bot._hf_text_to_image = original_hf
|
| 2828 |
bot.bot = original_bot_obj
|
| 2829 |
bot.chat_state.pop(chat_id, None)
|
| 2830 |
|
| 2831 |
|
| 2832 |
+
def test_inline_draw_stops_chain_on_service_rate_limit():
|
| 2833 |
+
# Реальный прод-инцидент 17.09.2026: ВСЕ 5 моделей вернули HTTP 429 подряд —
|
| 2834 |
+
# лимит всего сервиса, а не одной модели. Гонять остаток цепочки бессмысленно:
|
| 2835 |
+
# останавливаемся на первой же 429 и честно просим подождать (а не
|
| 2836 |
+
# "переформулируйте описание" — при перегрузке это неверный совет).
|
| 2837 |
+
chat_id = 999432
|
| 2838 |
+
attempts = []
|
| 2839 |
+
|
| 2840 |
+
async def fake_hf_text_to_image_always_429(session, model_id, prompt):
|
| 2841 |
+
attempts.append(model_id)
|
| 2842 |
+
raise RuntimeError("Pollinations.ai HTTP 429")
|
| 2843 |
+
|
| 2844 |
+
incoming = _FakeIncomingMessage(chat_id)
|
| 2845 |
+
incoming.message_id = 2
|
| 2846 |
+
|
| 2847 |
+
original_hf = bot._hf_text_to_image
|
| 2848 |
+
bot._hf_text_to_image = fake_hf_text_to_image_always_429
|
| 2849 |
+
try:
|
| 2850 |
+
asyncio.run(bot.inline_draw(incoming, "дикобраз"))
|
| 2851 |
+
assert attempts == [bot._pick_image_model("дикобраз")]
|
| 2852 |
+
status_texts = [text for text, _ in incoming.sent[0].edits]
|
| 2853 |
+
assert any("перегружен" in text for text in status_texts)
|
| 2854 |
+
finally:
|
| 2855 |
+
bot._hf_text_to_image = original_hf
|
| 2856 |
+
bot.chat_state.pop(chat_id, None)
|
| 2857 |
+
|
| 2858 |
+
|
| 2859 |
# ─────────────────── schema_version персистентного снимка чата (аудит техдолга) ───────────────────
|
| 2860 |
|
| 2861 |
def test_serialize_chat_state_stamps_current_schema_version():
|
test_lumen_router_config.py
CHANGED
|
@@ -113,7 +113,7 @@ def test_build_route_plain_text_prefers_openrouter_to_save_gemini_quota():
|
|
| 113 |
|
| 114 |
def test_build_route_heavy_plain_text_uses_strong_openrouter_models_first():
|
| 115 |
route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=True, needs_freshness=False)
|
| 116 |
-
assert route[0] == ("openrouter", "nvidia/nemotron-3-
|
| 117 |
|
| 118 |
|
| 119 |
def test_build_route_image_without_freshness_prefers_openrouter_vision():
|
|
@@ -440,8 +440,8 @@ def test_new_heavy_models_ordered_before_expiring_dots3_note_preview():
|
|
| 440 |
def test_or_heavy_order_still_headed_by_calibrated_flagships():
|
| 441 |
# Уже проверенные калибровкой сильные модели остаются головой — новые
|
| 442 |
# некалиброванные модели добавлены строго после них, не выше.
|
| 443 |
-
assert lumen_router_config._OR_HEAVY_ORDER[0] == "nvidia/nemotron-3-
|
| 444 |
-
assert lumen_router_config._OR_HEAVY_ORDER[1] == "nvidia/nemotron-3-
|
| 445 |
|
| 446 |
|
| 447 |
# ─────────────────── анонсированные ("Going away <дата>") даты снятия моделей ───────────────────
|
|
@@ -589,7 +589,7 @@ def test_sept_2026_new_light_models_after_proven_head_before_reserve():
|
|
| 589 |
assert order[0] == "nvidia/nemotron-3.5-lightning:free"
|
| 590 |
assert order[-1] == "openrouter/free"
|
| 591 |
for model_id in (
|
| 592 |
-
"
|
| 593 |
"inclusionai/ling-3.0-flash-sante:free", "inclusionai/ling-3.0-flash-fin:free",
|
| 594 |
"liquid/lfm-2.5-2.6b:free",
|
| 595 |
):
|
|
@@ -598,6 +598,25 @@ def test_sept_2026_new_light_models_after_proven_head_before_reserve():
|
|
| 598 |
assert model_id in lumen_router_config._KNOWN_MODEL_IDS_FOR_LEAK_DETECTION
|
| 599 |
|
| 600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
def test_sept_2026_new_heavy_and_vision_models_placed():
|
| 602 |
heavy = lumen_router_config._OR_HEAVY_ORDER
|
| 603 |
assert "thinkingmachines/inkling:free" in heavy
|
|
|
|
| 113 |
|
| 114 |
def test_build_route_heavy_plain_text_uses_strong_openrouter_models_first():
|
| 115 |
route = lumen_router_config._build_route(needs_youtube=False, needs_website=False, media_mime=None, is_heavy=True, needs_freshness=False)
|
| 116 |
+
assert route[0] == ("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free")
|
| 117 |
|
| 118 |
|
| 119 |
def test_build_route_image_without_freshness_prefers_openrouter_vision():
|
|
|
|
| 440 |
def test_or_heavy_order_still_headed_by_calibrated_flagships():
|
| 441 |
# Уже проверенные калибровкой сильные модели остаются головой — новые
|
| 442 |
# некалиброванные модели добавлены строго после них, не выше.
|
| 443 |
+
assert lumen_router_config._OR_HEAVY_ORDER[0] == "nvidia/nemotron-3-ultra-550b-a55b:free"
|
| 444 |
+
assert lumen_router_config._OR_HEAVY_ORDER[1] == "nvidia/nemotron-3-super-120b-a12b:free"
|
| 445 |
|
| 446 |
|
| 447 |
# ─────────────────── анонсированные ("Going away <дата>") даты снятия моделей ───────────────────
|
|
|
|
| 589 |
assert order[0] == "nvidia/nemotron-3.5-lightning:free"
|
| 590 |
assert order[-1] == "openrouter/free"
|
| 591 |
for model_id in (
|
| 592 |
+
"nex-agi/nex-n2.5-mini:free",
|
| 593 |
"inclusionai/ling-3.0-flash-sante:free", "inclusionai/ling-3.0-flash-fin:free",
|
| 594 |
"liquid/lfm-2.5-2.6b:free",
|
| 595 |
):
|
|
|
|
| 598 |
assert model_id in lumen_router_config._KNOWN_MODEL_IDS_FOR_LEAK_DETECTION
|
| 599 |
|
| 600 |
|
| 601 |
+
def test_inkling_small_excluded_after_agentic_harness_refusal():
|
| 602 |
+
# Вечер 17.09.2026, первый прод-трафик нового кода: inkling-small ответил
|
| 603 |
+
# отказом "only available on agentic harnesses" — чат-запросы не обслуживает.
|
| 604 |
+
assert "thinkingmachines/inkling-small:free" in lumen_router_config._OR_MODEL_HEALTH
|
| 605 |
+
assert "thinkingmachines/inkling-small:free" in lumen_router_config._ROUTER_EXCLUDED_OR_MODELS
|
| 606 |
+
assert "thinkingmachines/inkling-small:free" not in lumen_router_config._OR_LIGHT_ORDER
|
| 607 |
+
# nex-mini тем же вечером подтверждён живьём — занял его место вторым.
|
| 608 |
+
assert lumen_router_config._OR_LIGHT_ORDER[1] == "nex-agi/nex-n2.5-mini:free"
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def test_nemotron_super_demoted_below_ultra_after_second_mush_incident():
|
| 612 |
+
# Вечер 17.09.2026: super выдал "кашу" прямо в проде (второй инцидент после
|
| 613 |
+
# калибровочного) — понижен под ultra без инцидентов, но не исключён.
|
| 614 |
+
order = lumen_router_config._OR_HEAVY_ORDER
|
| 615 |
+
assert order[0] == "nvidia/nemotron-3-ultra-550b-a55b:free"
|
| 616 |
+
assert order[1] == "nvidia/nemotron-3-super-120b-a12b:free"
|
| 617 |
+
assert "nvidia/nemotron-3-super-120b-a12b:free" not in lumen_router_config._ROUTER_EXCLUDED_OR_MODELS
|
| 618 |
+
|
| 619 |
+
|
| 620 |
def test_sept_2026_new_heavy_and_vision_models_placed():
|
| 621 |
heavy = lumen_router_config._OR_HEAVY_ORDER
|
| 622 |
assert "thinkingmachines/inkling:free" in heavy
|