SilverElixir commited on
Commit
c022ec9
·
1 Parent(s): 04a179f

Rename HF_IMAGE_* to POLLINATIONS_* (backend is Pollinations, not Hugging Face)

Browse files
Files changed (5) hide show
  1. bot.py +13 -13
  2. docs/ENVIRONMENT.md +1 -1
  3. lumen_images.py +10 -10
  4. test_bot.py +28 -28
  5. test_lumen_images.py +5 -5
bot.py CHANGED
@@ -441,9 +441,9 @@ ROUTE_TOTAL_BUDGET_SEC = float(os.getenv("ROUTE_TOTAL_BUDGET_SEC", "40"))
441
  # DRAW_TOTAL_BUDGET_SEC — тот же принцип, что и ROUTE_TOTAL_BUDGET_SEC выше, но для
442
  # фолбэк-цепочки генерации изображений (см. inline_draw). НАЙДЕНО ПРИ КОД-РЕВЬЮ
443
  # (28 августа 2026): в отличие от текстового роутинга, у /draw не было ВООБЩЕ
444
- # никакого общего бюджета времени — каждый вызов _hf_text_to_image ждёт до 90с
445
  # (см. aiohttp.ClientTimeout в _pollinations_generate, lumen_images.py), а моделей
446
- # в HF_IMAGE_MODELS пять. Если Pollinations.ai лежит целиком, пользователь мог
447
  # ждать до ~7.5 минут, прежде чем увидеть любую ошибку — статусное сообщение
448
  # "Генерирую изображение" всё это время просто висело. 120с — достаточно на одну
449
  # полную попытку (90с) плюс запас на вторую, но ограничивает худший случай вдвое
@@ -1954,7 +1954,7 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
1954
  # Pollinations.ai) вынесена в lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
1955
  # не зовёт Telegram API и не зависит от глобальных bot/client, самый изолированный
1956
  # кандидат из пяти намеченных. Единственное отличие от прежнего кода:
1957
- # _pollinations_generate/_hf_text_to_image теперь принимают уже готовую aiohttp-сессию
1958
  # параметром (см. докстринг модуля) — раньше сессия получалась неявно через
1959
  # _get_http_session() внутри самой функции, что означало бы либо тянуть этот геттер
1960
  # в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
@@ -1964,17 +1964,17 @@ async def _is_privileged_in_chat(chat_type: str, chat_id: int, user_id: int | No
1964
  # показывает названия моделей генерации ни в статусе, ни в подписи — см.
1965
  # ИДЕНТИЧНОСТЬ в system_prompt.py. Сама функция живёт в lumen_images.py.
1966
  from lumen_images import (
1967
- DEFAULT_HF_IMAGE_MODEL,
1968
- HF_IMAGE_MODELS,
1969
  _pick_image_model,
1970
- _hf_text_to_image,
1971
  )
1972
 
1973
- # DEFAULT_HF_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
1974
  # (используется только внутри самой _pick_image_model в lumen_images.py) — но
1975
- # остаётся нужен как `bot.DEFAULT_HF_IMAGE_MODEL` для существующих тестов. См.
1976
  # пояснение про __all__ у первого блока (lumen_formatting) в начале файла.
1977
- __all__ += ["DEFAULT_HF_IMAGE_MODEL"]
1978
 
1979
  # метаданные и скачивание медиа
1980
 
@@ -4183,8 +4183,8 @@ async def inline_draw(message: Message, prompt: str) -> None:
4183
  primary_model = _pick_image_model(prompt)
4184
 
4185
  # Фолбэк-цепочка: пробуем сначала подобранную модель,
4186
- # при ошибке переключаемся на следующие по порядку из HF_IMAGE_MODELS
4187
- all_model_ids = list(HF_IMAGE_MODELS.keys())
4188
  fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
4189
 
4190
  image_bytes = None
@@ -4207,7 +4207,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
4207
  # На первой попытке статус и так "Генерирую изображение" — не трогаем.
4208
  if attempt_model != primary_model:
4209
  await _edit_message_quietly(status, "Это займёт немного больше времени…")
4210
- image_bytes = await _hf_text_to_image(session, attempt_model, prompt)
4211
  break
4212
  except Exception as exc:
4213
  last_error = exc
@@ -4242,7 +4242,7 @@ async def inline_draw(message: Message, prompt: str) -> None:
4242
  raise last_error or RuntimeError("all image generation models unavailable")
4243
 
4244
  except Exception as exc:
4245
- log.exception("Hugging Face image generation failed:")
4246
  txt = _error_text(exc).strip()
4247
  if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
4248
  user_err = "Сервис генерации изображений временно недоступен. Попробуй позже."
 
441
  # DRAW_TOTAL_BUDGET_SEC — тот же принцип, что и ROUTE_TOTAL_BUDGET_SEC выше, но для
442
  # фолбэк-цепочки генерации изображений (см. inline_draw). НАЙДЕНО ПРИ КОД-РЕВЬЮ
443
  # (28 августа 2026): в отличие от текстового роутинга, у /draw не было ВООБЩЕ
444
+ # никакого общего бюджета времени — каждый вызов _pollinations_text_to_image ждёт до 90с
445
  # (см. aiohttp.ClientTimeout в _pollinations_generate, lumen_images.py), а моделей
446
+ # в POLLINATIONS_IMAGE_MODELS пять. Если Pollinations.ai лежит целиком, пользователь мог
447
  # ждать до ~7.5 минут, прежде чем увидеть любую ошибку — статусное сообщение
448
  # "Генерирую изображение" всё это время просто висело. 120с — достаточно на одну
449
  # полную попытку (90с) плюс запас на вторую, но ограничивает худший случай вдвое
 
1954
  # Pollinations.ai) вынесена в lumen_images.py — не пишет в chat_state/GLOBAL_QUOTA,
1955
  # не зовёт Telegram API и не зависит от глобальных bot/client, самый изолированный
1956
  # кандидат из пяти намеченных. Единственное отличие от прежнего кода:
1957
+ # _pollinations_generate/_pollinations_text_to_image теперь принимают уже готовую aiohttp-сессию
1958
  # параметром (см. докстринг модуля) — раньше сессия получалась неявно через
1959
  # _get_http_session() внутри самой функции, что означало бы либо тянуть этот геттер
1960
  # в новый модуль, либо заводить там свой отдельный источник сессий; вызывающий код
 
1964
  # показывает названия моделей генерации ни в статусе, ни в подписи — см.
1965
  # ИДЕНТИЧНОСТЬ в system_prompt.py. Сама функция живёт в lumen_images.py.
1966
  from lumen_images import (
1967
+ DEFAULT_POLLINATIONS_IMAGE_MODEL,
1968
+ POLLINATIONS_IMAGE_MODELS,
1969
  _pick_image_model,
1970
+ _pollinations_text_to_image,
1971
  )
1972
 
1973
+ # DEFAULT_POLLINATIONS_IMAGE_MODEL больше не читается напрямую нигде в остальном коде bot.py
1974
  # (используется только внутри самой _pick_image_model в lumen_images.py) — но
1975
+ # остаётся нужен как `bot.DEFAULT_POLLINATIONS_IMAGE_MODEL` для существующих тестов. См.
1976
  # пояснение про __all__ у первого блока (lumen_formatting) в начале файла.
1977
+ __all__ += ["DEFAULT_POLLINATIONS_IMAGE_MODEL"]
1978
 
1979
  # метаданные и скачивание медиа
1980
 
 
4183
  primary_model = _pick_image_model(prompt)
4184
 
4185
  # Фолбэк-цепочка: пробуем сначала подобранную модель,
4186
+ # при ошибке переключаемся на следующие по порядку из POLLINATIONS_IMAGE_MODELS
4187
+ all_model_ids = list(POLLINATIONS_IMAGE_MODELS.keys())
4188
  fallback_chain = [primary_model] + [m for m in all_model_ids if m != primary_model]
4189
 
4190
  image_bytes = None
 
4207
  # На первой попытке статус и так "Генерирую изображение" — не трогаем.
4208
  if attempt_model != primary_model:
4209
  await _edit_message_quietly(status, "Это займёт немного больше времени…")
4210
+ image_bytes = await _pollinations_text_to_image(session, attempt_model, prompt)
4211
  break
4212
  except Exception as exc:
4213
  last_error = exc
 
4242
  raise last_error or RuntimeError("all image generation models unavailable")
4243
 
4244
  except Exception as exc:
4245
+ log.exception("Pollinations image generation failed:")
4246
  txt = _error_text(exc).strip()
4247
  if any(kw in txt.lower() for kw in ("cannot connect", "ssl:", "no address", "connection", "timeout", "host")):
4248
  user_err = "Сервис генерации изображений временно недоступен. Попробуй позже."
docs/ENVIRONMENT.md CHANGED
@@ -50,7 +50,7 @@ Before publishing, configure the same `LUMEN_PROXY_SECRET` on HF and every Deno
50
  | `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
51
  | `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
52
  | `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
53
- | `HF_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). |
54
 
55
  ## Timeouts, rate limiting & routing budgets
56
 
 
50
  | `OPENROUTER_API_KEY` / `OPENROUTER_KEY` | — | OpenRouter API key. Without it, the router sends everything to Gemini, which burns through its much smaller quota fast. |
51
  | `OPENROUTER_HTTP_REFERER` | `https://t.me/{BOT_USERNAME}` | `HTTP-Referer` header sent with OpenRouter requests. |
52
  | `OPENROUTER_TITLE` | `BOT_USERNAME` | App title header sent with OpenRouter requests. |
53
+ | `POLLINATIONS_IMAGE_MODEL` | `flux` | Default image-generation model (used only if the prompt doesn't match a more specific style). Renamed from `HF_IMAGE_MODEL` (Sept 2026) — the backend is Pollinations.ai, not Hugging Face; the old name is no longer read. |
54
 
55
  ## Timeouts, rate limiting & routing budgets
56
 
lumen_images.py CHANGED
@@ -11,7 +11,7 @@ lumen_images.py — генерация изображений через Pollina
11
  есть один общий httр-session getter (`_get_http_session`), которым пользуются TikTok/
12
  OpenRouter/эта же генерация картинок — плодить второй, изолированный источник управления
13
  HTTP-соединениями было бы речь не о разделении ответственности, а о случайном дублировании.
14
- Поэтому `_pollinations_generate`/`_hf_text_to_image` принимают уже готовую сессию параметром;
15
  вызывающий код (см. `inline_draw` в bot.py) сам получает её через `_get_http_session()` и
16
  передаёт сюда — то же самое соглашение, по которому TikTok-скачивание в bot.py принимает
17
  сессию параметром в `_download_url_bin`/`_resolve_tiktok_short` и т.п.
@@ -37,9 +37,9 @@ from typing import Any
37
 
38
  import aiohttp
39
 
40
- DEFAULT_HF_IMAGE_MODEL = os.getenv("HF_IMAGE_MODEL", "flux").strip()
41
 
42
- HF_IMAGE_MODELS: dict[str, dict[str, Any]] = {
43
  "flux": {
44
  "name": "FLUX Pro",
45
  "desc": "Высококачественный FLUX. Фотореализм, точное следование промпту, богатая детализация.",
@@ -70,7 +70,7 @@ HF_IMAGE_MODELS: dict[str, dict[str, Any]] = {
70
  # существующей fallback-цепочке (см. inline_draw в bot.py) при неудаче/плохом
71
  # результате. Порядок проверки — от самых специфичных категорий к общей: аниме/
72
  # фэнтези/реализм/черновик — явные сигналы жанра, при их отсутствии остаётся
73
- # DEFAULT_HF_IMAGE_MODEL (универсальный FLUX Pro).
74
  _ANIME_RE = re.compile(r"аниме|манг[аи]|манхв\w*|вебтун\w*|чиби|ваифу|anime|manga|waifu|chibi", re.IGNORECASE)
75
  _FANTASY_RE = re.compile(
76
  r"фэнтези|фентези|фентезийн\w*|концепт-?арт\w*|дракон\w*|эльф\w*|волшебн\w*|магическ\w*|"
@@ -95,7 +95,7 @@ def _pick_image_model(prompt: str) -> str:
95
  не стиль, поэтому последний перед дефолтом (стилевые сигналы важнее просьбы
96
  "побыстрее", если оба есть в одном промпте)."""
97
  if not prompt:
98
- return DEFAULT_HF_IMAGE_MODEL
99
  if _ANIME_RE.search(prompt):
100
  return "flux-anime"
101
  if _FANTASY_RE.search(prompt):
@@ -104,7 +104,7 @@ def _pick_image_model(prompt: str) -> str:
104
  return "flux-realism"
105
  if _QUICK_RE.search(prompt):
106
  return "turbo"
107
- return DEFAULT_HF_IMAGE_MODEL
108
 
109
 
110
  async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
@@ -126,17 +126,17 @@ async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str
126
  raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
127
 
128
 
129
- async def _hf_text_to_image(session: aiohttp.ClientSession, model_id: str, prompt: str) -> bytes:
130
  # Pollinations.ai — единственный провайдер генерации изображений (HF Inference
131
  # API-ветка убрана: старые HF-модели регулярно устаревали на стороне провайдера,
132
  # см. историю: FLUX.1-dev вернул 410 Gone). Раз провайдер всего один, отдельная
133
- # "pollinations:" приставка на каждом ключе HF_IMAGE_MODELS была лишней —
134
  # model_id и есть имя модели Pollinations как есть.
135
- if model_id not in HF_IMAGE_MODELS:
136
  raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
137
  return await _pollinations_generate(session, model_id, prompt)
138
 
139
 
140
  def _image_model_label(model_id: str) -> str:
141
- meta = HF_IMAGE_MODELS.get(model_id, {})
142
  return meta.get("name", model_id)
 
11
  есть один общий httр-session getter (`_get_http_session`), которым пользуются TikTok/
12
  OpenRouter/эта же генерация картинок — плодить второй, изолированный источник управления
13
  HTTP-соединениями было бы речь не о разделении ответственности, а о случайном дублировании.
14
+ Поэтому `_pollinations_generate`/`_pollinations_text_to_image` принимают уже готовую сессию параметром;
15
  вызывающий код (см. `inline_draw` в bot.py) сам получает её через `_get_http_session()` и
16
  передаёт сюда — то же самое соглашение, по которому TikTok-скачивание в bot.py принимает
17
  сессию параметром в `_download_url_bin`/`_resolve_tiktok_short` и т.п.
 
37
 
38
  import aiohttp
39
 
40
+ DEFAULT_POLLINATIONS_IMAGE_MODEL = os.getenv("POLLINATIONS_IMAGE_MODEL", "flux").strip()
41
 
42
+ POLLINATIONS_IMAGE_MODELS: dict[str, dict[str, Any]] = {
43
  "flux": {
44
  "name": "FLUX Pro",
45
  "desc": "Высококачественный FLUX. Фотореализм, точное следование промпту, богатая детализация.",
 
70
  # существующей fallback-цепочке (см. inline_draw в bot.py) при неудаче/плохом
71
  # результате. Порядок проверки — от самых специфичных категорий к общей: аниме/
72
  # фэнтези/реализм/черновик — явные сигналы жанра, при их отсутствии остаётся
73
+ # DEFAULT_POLLINATIONS_IMAGE_MODEL (универсальный FLUX Pro).
74
  _ANIME_RE = re.compile(r"аниме|манг[аи]|манхв\w*|вебтун\w*|чиби|ваифу|anime|manga|waifu|chibi", re.IGNORECASE)
75
  _FANTASY_RE = re.compile(
76
  r"фэнтези|фентези|фентезийн\w*|концепт-?арт\w*|дракон\w*|эльф\w*|волшебн\w*|магическ\w*|"
 
95
  не стиль, поэтому последний перед дефолтом (стилевые сигналы важнее просьбы
96
  "побыстрее", если оба есть в одном промпте)."""
97
  if not prompt:
98
+ return DEFAULT_POLLINATIONS_IMAGE_MODEL
99
  if _ANIME_RE.search(prompt):
100
  return "flux-anime"
101
  if _FANTASY_RE.search(prompt):
 
104
  return "flux-realism"
105
  if _QUICK_RE.search(prompt):
106
  return "turbo"
107
+ return DEFAULT_POLLINATIONS_IMAGE_MODEL
108
 
109
 
110
  async def _pollinations_generate(session: aiohttp.ClientSession, model_name: str, prompt: str) -> bytes:
 
126
  raise RuntimeError(f"Pollinations.ai HTTP {resp.status}")
127
 
128
 
129
+ async def _pollinations_text_to_image(session: aiohttp.ClientSession, model_id: str, prompt: str) -> bytes:
130
  # Pollinations.ai — единственный провайдер генерации изображений (HF Inference
131
  # API-ветка убрана: старые HF-модели регулярно устаревали на стороне провайдера,
132
  # см. историю: FLUX.1-dev вернул 410 Gone). Раз провайдер всего один, отдельная
133
+ # "pollinations:" приставка на каждом ключе POLLINATIONS_IMAGE_MODELS была лишней —
134
  # model_id и есть имя модели Pollinations как есть.
135
+ if model_id not in POLLINATIONS_IMAGE_MODELS:
136
  raise ValueError(f"Неизвестная модель генерации изображений: {model_id}")
137
  return await _pollinations_generate(session, model_id, prompt)
138
 
139
 
140
  def _image_model_label(model_id: str) -> str:
141
+ meta = POLLINATIONS_IMAGE_MODELS.get(model_id, {})
142
  return meta.get("name", model_id)
test_bot.py CHANGED
@@ -705,7 +705,7 @@ def test_save_chat_to_storage_returns_true_on_success(tmp_path):
705
  # ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
706
  # а не просто логировать исключение и возвращать None в обоих случаях.
707
  chat_id = 999601
708
- state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
709
  original_chats_dir = bot._CHATS_DIR
710
  bot._CHATS_DIR = tmp_path
711
  try:
@@ -716,7 +716,7 @@ def test_save_chat_to_storage_returns_true_on_success(tmp_path):
716
 
717
 
718
  def test_save_chat_to_storage_returns_false_on_failure():
719
- state = {"history": [], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
720
  with patch("bot._storage_write_text", side_effect=RuntimeError("сбой хранилища")):
721
  assert bot._save_chat_to_storage(999602, state) is False
722
 
@@ -749,8 +749,8 @@ def test_flush_dirty_state_once_requeues_failed_saves():
749
  # заново. Теперь чат, для которого сохранение не удалось, должен остаться в
750
  # _dirty_chat_ids и попасть в следующий цикл.
751
  ok_chat, fail_chat = 999701, 999702
752
- bot.chat_state[ok_chat] = {"history": [{"role": "user", "content": "ok"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
753
- bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
754
  bot._dirty_chat_ids.clear()
755
  bot._dirty_chat_ids.update({ok_chat, fail_chat})
756
  bot._index_dirty = False
@@ -2793,8 +2793,8 @@ def test_pick_image_model_detects_quick_draft():
2793
 
2794
 
2795
  def test_pick_image_model_falls_back_to_default_for_generic_prompt():
2796
- assert bot._pick_image_model("космическая станция на орбите Земли") == bot.DEFAULT_HF_IMAGE_MODEL
2797
- assert bot._pick_image_model("") == bot.DEFAULT_HF_IMAGE_MODEL
2798
 
2799
 
2800
  def test_pick_image_model_style_keyword_wins_over_quick_keyword():
@@ -2838,7 +2838,7 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
2838
  chat_id = 999430
2839
  captured_model = []
2840
 
2841
- async def fake_hf_text_to_image(session, model_id, prompt):
2842
  captured_model.append(model_id)
2843
  return b"\x89PNG fake bytes"
2844
 
@@ -2849,9 +2849,9 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
2849
  async def send_photo(self, **kwargs):
2850
  return SimpleNamespace()
2851
 
2852
- original_hf = bot._hf_text_to_image
2853
  original_bot_obj = bot.bot
2854
- bot._hf_text_to_image = fake_hf_text_to_image
2855
  bot.bot = _FakePhotoBot()
2856
  try:
2857
  asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
@@ -2860,7 +2860,7 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
2860
  # завязан на состояние чата вообще.
2861
  assert "image_model" not in bot.get_state(chat_id)
2862
  finally:
2863
- bot._hf_text_to_image = original_hf
2864
  bot.bot = original_bot_obj
2865
  bot.chat_state.pop(chat_id, None)
2866
 
@@ -2868,14 +2868,14 @@ def test_inline_draw_picks_model_from_prompt_without_touching_chat_state():
2868
  def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
2869
  # Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
2870
  # общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
2871
- # генерации бот перебирал бы все 5 моделей HF_IMAGE_MODELS, тратя реальное
2872
  # время пользователя без единого предупреждения. Патчим DRAW_TOTAL_BUDGET_SEC
2873
  # на крошечное значение и делаем первую попытку заведомо дольше него —
2874
  # вторая попытка не должна была вообще начаться.
2875
  chat_id = 999432
2876
  attempts = []
2877
 
2878
- async def fake_hf_text_to_image(session, model_id, prompt):
2879
  attempts.append(model_id)
2880
  await asyncio.sleep(0.05) # дольше урезанного DRAW_TOTAL_BUDGET_SEC ниже
2881
  raise RuntimeError("503 Service Unavailable")
@@ -2883,9 +2883,9 @@ def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
2883
  incoming = _FakeIncomingMessage(chat_id)
2884
  incoming.message_id = 1
2885
 
2886
- original_hf = bot._hf_text_to_image
2887
  original_budget = bot.DRAW_TOTAL_BUDGET_SEC
2888
- bot._hf_text_to_image = fake_hf_text_to_image
2889
  bot.DRAW_TOTAL_BUDGET_SEC = 0.01
2890
  try:
2891
  asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
@@ -2893,7 +2893,7 @@ def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
2893
  assert len(attempts) == 1
2894
  assert "времени" in incoming.sent[0].edits[-1][0].lower()
2895
  finally:
2896
- bot._hf_text_to_image = original_hf
2897
  bot.DRAW_TOTAL_BUDGET_SEC = original_budget
2898
 
2899
 
@@ -2905,7 +2905,7 @@ def test_inline_draw_falls_back_when_auto_picked_model_fails():
2905
  chat_id = 999431
2906
  attempts = []
2907
 
2908
- async def fake_hf_text_to_image(session, model_id, prompt):
2909
  attempts.append(model_id)
2910
  if model_id == "flux-anime":
2911
  raise RuntimeError("503 Service Unavailable")
@@ -2920,23 +2920,23 @@ def test_inline_draw_falls_back_when_auto_picked_model_fails():
2920
  captured_photo.update(kwargs)
2921
  return SimpleNamespace()
2922
 
2923
- original_hf = bot._hf_text_to_image
2924
  original_bot_obj = bot.bot
2925
- bot._hf_text_to_image = fake_hf_text_to_image
2926
  bot.bot = _FakePhotoBot()
2927
  try:
2928
  asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
2929
  # Авто-подобранная модель (flux-anime) пробуется ПЕРВОЙ, несмотря на сбой.
2930
  assert attempts[0] == "flux-anime"
2931
  # Реально отправленное изображение — от следующей модели по порядку
2932
- # HF_IMAGE_MODELS, а не от auto-pick, провалившегося с ошибкой.
2933
  assert len(attempts) >= 2
2934
  assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
2935
  # Подпись с названием модели убрана (сентябрь 2026): бот не раскрывает
2936
  # внутреннюю реализацию — ни названий, ни "основная недоступна".
2937
  assert "caption" not in captured_photo
2938
  finally:
2939
- bot._hf_text_to_image = original_hf
2940
  bot.bot = original_bot_obj
2941
  bot.chat_state.pop(chat_id, None)
2942
 
@@ -2949,29 +2949,29 @@ def test_inline_draw_stops_chain_on_service_rate_limit():
2949
  chat_id = 999432
2950
  attempts = []
2951
 
2952
- async def fake_hf_text_to_image_always_429(session, model_id, prompt):
2953
  attempts.append(model_id)
2954
  raise RuntimeError("Pollinations.ai HTTP 429")
2955
 
2956
  incoming = _FakeIncomingMessage(chat_id)
2957
  incoming.message_id = 2
2958
 
2959
- original_hf = bot._hf_text_to_image
2960
- bot._hf_text_to_image = fake_hf_text_to_image_always_429
2961
  try:
2962
  asyncio.run(bot.inline_draw(incoming, "дикобраз"))
2963
  assert attempts == [bot._pick_image_model("дикобраз")]
2964
  status_texts = [text for text, _ in incoming.sent[0].edits]
2965
  assert any("перегружен" in text for text in status_texts)
2966
  finally:
2967
- bot._hf_text_to_image = original_hf
2968
  bot.chat_state.pop(chat_id, None)
2969
 
2970
 
2971
  # ─────────────────── schema_version персистентного снимка чата (ауди�� техдолга) ───────────────────
2972
 
2973
  def test_serialize_chat_state_stamps_current_schema_version():
2974
- state = {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [], "quota": {}, "recent_media_ids": {}}
2975
  snapshot = bot._serialize_chat_state(state)
2976
  assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION
2977
 
@@ -2981,7 +2981,7 @@ def test_restore_single_chat_accepts_legacy_record_without_schema_version():
2981
  # восстановление не должно падать и должно вести себя так же, как раньше.
2982
  cid = 999905
2983
  try:
2984
- bot._restore_single_chat(cid, {"image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}]})
2985
  assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
2986
  finally:
2987
  bot.chat_state.pop(cid, None)
@@ -2991,7 +2991,7 @@ def test_restore_single_chat_accepts_current_schema_version_record():
2991
  cid = 999906
2992
  try:
2993
  snapshot = bot._serialize_chat_state({
2994
- "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
2995
  "quota": {}, "recent_media_ids": {},
2996
  })
2997
  bot._restore_single_chat(cid, snapshot)
@@ -3734,7 +3734,7 @@ def test_export_state_returns_chats_and_quota_with_valid_key():
3734
  bot.ADMIN_PANEL_KEY = "real-admin-key"
3735
  chat_id = 999411
3736
  bot.chat_state[chat_id] = {
3737
- "image_model": bot.DEFAULT_HF_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
3738
  "quota": {}, "recent_media_ids": {}, "last_activity": 0.0,
3739
  }
3740
  try:
 
705
  # ниже): функция теперь ДОЛЖНА сигнализировать успех/неудачу вызывающему коду,
706
  # а не просто логировать исключение и возвращать None в обоих случаях.
707
  chat_id = 999601
708
+ state = {"history": [{"role": "user", "content": "привет"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
709
  original_chats_dir = bot._CHATS_DIR
710
  bot._CHATS_DIR = tmp_path
711
  try:
 
716
 
717
 
718
  def test_save_chat_to_storage_returns_false_on_failure():
719
+ state = {"history": [], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
720
  with patch("bot._storage_write_text", side_effect=RuntimeError("сбой хранилища")):
721
  assert bot._save_chat_to_storage(999602, state) is False
722
 
 
749
  # заново. Теперь чат, для которого сохранение не удалось, должен остаться в
750
  # _dirty_chat_ids и попасть в следующий цикл.
751
  ok_chat, fail_chat = 999701, 999702
752
+ bot.chat_state[ok_chat] = {"history": [{"role": "user", "content": "ok"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
753
+ bot.chat_state[fail_chat] = {"history": [{"role": "user", "content": "fail"}], "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "quota": {}, "recent_media_ids": {}}
754
  bot._dirty_chat_ids.clear()
755
  bot._dirty_chat_ids.update({ok_chat, fail_chat})
756
  bot._index_dirty = False
 
2793
 
2794
 
2795
  def test_pick_image_model_falls_back_to_default_for_generic_prompt():
2796
+ assert bot._pick_image_model("космическая станция на орбите Земли") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
2797
+ assert bot._pick_image_model("") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
2798
 
2799
 
2800
  def test_pick_image_model_style_keyword_wins_over_quick_keyword():
 
2838
  chat_id = 999430
2839
  captured_model = []
2840
 
2841
+ async def fake_pollinations_text_to_image(session, model_id, prompt):
2842
  captured_model.append(model_id)
2843
  return b"\x89PNG fake bytes"
2844
 
 
2849
  async def send_photo(self, **kwargs):
2850
  return SimpleNamespace()
2851
 
2852
+ original_hf = bot._pollinations_text_to_image
2853
  original_bot_obj = bot.bot
2854
+ bot._pollinations_text_to_image = fake_pollinations_text_to_image
2855
  bot.bot = _FakePhotoBot()
2856
  try:
2857
  asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
 
2860
  # завязан на состояние чата вообще.
2861
  assert "image_model" not in bot.get_state(chat_id)
2862
  finally:
2863
+ bot._pollinations_text_to_image = original_hf
2864
  bot.bot = original_bot_obj
2865
  bot.chat_state.pop(chat_id, None)
2866
 
 
2868
  def test_inline_draw_stops_fallback_chain_when_time_budget_exceeded():
2869
  # Регрессия на находку код-ревью (28 августа 2026): раньше у /draw не было
2870
  # общего бюджета времени на всю фолбэк-цепочку — при недоступности сервиса
2871
+ # генерации бот перебирал бы все 5 моделей POLLINATIONS_IMAGE_MODELS, тратя реальное
2872
  # время пользователя без единого предупреждения. Патчим DRAW_TOTAL_BUDGET_SEC
2873
  # на крошечное значение и делаем первую попытку заведомо дольше него —
2874
  # вторая попытка не должна была вообще начаться.
2875
  chat_id = 999432
2876
  attempts = []
2877
 
2878
+ async def fake_pollinations_text_to_image(session, model_id, prompt):
2879
  attempts.append(model_id)
2880
  await asyncio.sleep(0.05) # дольше урезанного DRAW_TOTAL_BUDGET_SEC ниже
2881
  raise RuntimeError("503 Service Unavailable")
 
2883
  incoming = _FakeIncomingMessage(chat_id)
2884
  incoming.message_id = 1
2885
 
2886
+ original_hf = bot._pollinations_text_to_image
2887
  original_budget = bot.DRAW_TOTAL_BUDGET_SEC
2888
+ bot._pollinations_text_to_image = fake_pollinations_text_to_image
2889
  bot.DRAW_TOTAL_BUDGET_SEC = 0.01
2890
  try:
2891
  asyncio.run(bot.inline_draw(incoming, "нарисуй кота"))
 
2893
  assert len(attempts) == 1
2894
  assert "времени" in incoming.sent[0].edits[-1][0].lower()
2895
  finally:
2896
+ bot._pollinations_text_to_image = original_hf
2897
  bot.DRAW_TOTAL_BUDGET_SEC = original_budget
2898
 
2899
 
 
2905
  chat_id = 999431
2906
  attempts = []
2907
 
2908
+ async def fake_pollinations_text_to_image(session, model_id, prompt):
2909
  attempts.append(model_id)
2910
  if model_id == "flux-anime":
2911
  raise RuntimeError("503 Service Unavailable")
 
2920
  captured_photo.update(kwargs)
2921
  return SimpleNamespace()
2922
 
2923
+ original_hf = bot._pollinations_text_to_image
2924
  original_bot_obj = bot.bot
2925
+ bot._pollinations_text_to_image = fake_pollinations_text_to_image
2926
  bot.bot = _FakePhotoBot()
2927
  try:
2928
  asyncio.run(bot.inline_draw(incoming, "нарисуй девушку в стиле аниме на пляже"))
2929
  # Авто-подобранная модель (flux-anime) пробуется ПЕРВОЙ, несмотря на сбой.
2930
  assert attempts[0] == "flux-anime"
2931
  # Реально отправленное изображение — от следующей модели по порядку
2932
+ # POLLINATIONS_IMAGE_MODELS, а не от auto-pick, провалившегося с ошибкой.
2933
  assert len(attempts) >= 2
2934
  assert captured_photo["photo"].data == b"\x89PNG fallback bytes"
2935
  # Подпись с названием модели убрана (сентябрь 2026): бот не раскрывает
2936
  # внутреннюю реализацию — ни названий, ни "основная недоступна".
2937
  assert "caption" not in captured_photo
2938
  finally:
2939
+ bot._pollinations_text_to_image = original_hf
2940
  bot.bot = original_bot_obj
2941
  bot.chat_state.pop(chat_id, None)
2942
 
 
2949
  chat_id = 999432
2950
  attempts = []
2951
 
2952
+ async def fake_pollinations_text_to_image_always_429(session, model_id, prompt):
2953
  attempts.append(model_id)
2954
  raise RuntimeError("Pollinations.ai HTTP 429")
2955
 
2956
  incoming = _FakeIncomingMessage(chat_id)
2957
  incoming.message_id = 2
2958
 
2959
+ original_hf = bot._pollinations_text_to_image
2960
+ bot._pollinations_text_to_image = fake_pollinations_text_to_image_always_429
2961
  try:
2962
  asyncio.run(bot.inline_draw(incoming, "дикобраз"))
2963
  assert attempts == [bot._pick_image_model("дикобраз")]
2964
  status_texts = [text for text, _ in incoming.sent[0].edits]
2965
  assert any("перегружен" in text for text in status_texts)
2966
  finally:
2967
+ bot._pollinations_text_to_image = original_hf
2968
  bot.chat_state.pop(chat_id, None)
2969
 
2970
 
2971
  # ─────────────────── schema_version персистентного снимка чата (ауди�� техдолга) ───────────────────
2972
 
2973
  def test_serialize_chat_state_stamps_current_schema_version():
2974
+ state = {"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [], "quota": {}, "recent_media_ids": {}}
2975
  snapshot = bot._serialize_chat_state(state)
2976
  assert snapshot["schema_version"] == bot.CHAT_STATE_SCHEMA_VERSION
2977
 
 
2981
  # восстановление не должно падать и должно вести себя так же, как раньше.
2982
  cid = 999905
2983
  try:
2984
+ bot._restore_single_chat(cid, {"image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}]})
2985
  assert bot.chat_state[cid]["history"] == [{"role": "user", "content": "hi"}]
2986
  finally:
2987
  bot.chat_state.pop(cid, None)
 
2991
  cid = 999906
2992
  try:
2993
  snapshot = bot._serialize_chat_state({
2994
+ "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
2995
  "quota": {}, "recent_media_ids": {},
2996
  })
2997
  bot._restore_single_chat(cid, snapshot)
 
3734
  bot.ADMIN_PANEL_KEY = "real-admin-key"
3735
  chat_id = 999411
3736
  bot.chat_state[chat_id] = {
3737
+ "image_model": bot.DEFAULT_POLLINATIONS_IMAGE_MODEL, "history": [{"role": "user", "content": "hi"}],
3738
  "quota": {}, "recent_media_ids": {}, "last_activity": 0.0,
3739
  }
3740
  try:
test_lumen_images.py CHANGED
@@ -39,12 +39,12 @@ def test_pick_image_model_quick_draft():
39
 
40
 
41
  def test_pick_image_model_falls_back_to_default_for_generic_prompt():
42
- assert lumen_images._pick_image_model("космическая станция на орбите Земли") == lumen_images.DEFAULT_HF_IMAGE_MODEL
43
- assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.DEFAULT_HF_IMAGE_MODEL
44
 
45
 
46
  def test_pick_image_model_empty_prompt_returns_default():
47
- assert lumen_images._pick_image_model("") == lumen_images.DEFAULT_HF_IMAGE_MODEL
48
 
49
 
50
  def test_pick_image_model_style_keyword_wins_over_quick_keyword():
@@ -59,10 +59,10 @@ def test_pick_image_model_case_insensitive():
59
 
60
  def test_pick_image_model_result_always_a_known_model():
61
  # Регрессия на класс ошибок "эвристика вернула ID, которого нет в каталоге" —
62
- # неважно, какой промпт, результат обязан быть валидным ключом HF_IMAGE_MODELS.
63
  prompts = [
64
  "нарисуй кота", "аниме", "фэнтези дракон", "реалистичное фото гор",
65
  "быстрый скетч", "", "случайный текст без ключевых слов вообще",
66
  ]
67
  for p in prompts:
68
- assert lumen_images._pick_image_model(p) in lumen_images.HF_IMAGE_MODELS
 
39
 
40
 
41
  def test_pick_image_model_falls_back_to_default_for_generic_prompt():
42
+ assert lumen_images._pick_image_model("космическая станция на орбите Земли") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
43
+ assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
44
 
45
 
46
  def test_pick_image_model_empty_prompt_returns_default():
47
+ assert lumen_images._pick_image_model("") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
48
 
49
 
50
  def test_pick_image_model_style_keyword_wins_over_quick_keyword():
 
59
 
60
  def test_pick_image_model_result_always_a_known_model():
61
  # Регрессия на класс ошибок "эвристика вернула ID, которого нет в каталоге" —
62
+ # неважно, какой промпт, результат обязан быть валидным ключом POLLINATIONS_IMAGE_MODELS.
63
  prompts = [
64
  "нарисуй кота", "аниме", "фэнтези дракон", "реалистичное фото гор",
65
  "быстрый скетч", "", "случайный текст без ключевых слов вообще",
66
  ]
67
  for p in prompts:
68
+ assert lumen_images._pick_image_model(p) in lumen_images.POLLINATIONS_IMAGE_MODELS