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 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}: прокси сейчас помечен недоступным (см. предыдущие [telegram] предупреждения), не дёргаю сеть повторно.")
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"Сетевая ошибка OpenRouter: {exc_str}") from exc
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("Превышено допустимое число попыток обращения к Gemini API.")
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 вернул ошибку в теле стрима", status_code=err_code)
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
- "Пришлите ссылку — скачаю видео или фото без водяных знаков.\n\n"
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("Превышен общий бюджет времени н�� генерацию изображения") from last_error
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 "все модели" in txt.lower():
4128
- user_err = "Все модели генерации изображений сейчас недоступны. Попробуйте позже."
4129
- elif "бюджет времени" in txt.lower():
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, "Укажите текст после команды /draw. Пример: /draw космическая станция")
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, "Укажите текст после команды /tts. Пример: /tts Добрый день")
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"Бюджет времени на маршрут исчерпан. Испробовано: {', '.join(tried) or '—'}")
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
- # калибровочное тестирование: 1 инцидент из 4 тяжёлых запросов (25 июля 2026 —
555
- # китайский иероглиф вместо "хвост" в ответе про TCP/IP). Не понижена — один
556
- # инцидент на четыре успешных попытки не повод убирать флагмана, но стоит
557
- # присматривать за логами `[stream]` этой модели.
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
- assert "основная модель недоступна" in captured_photo["caption"]
 
 
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-super-120b-a12b:free")
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-super-120b-a12b:free"
444
- assert lumen_router_config._OR_HEAVY_ORDER[1] == "nvidia/nemotron-3-ultra-550b-a55b:free"
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
- "thinkingmachines/inkling-small:free", "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,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