Lumen / lumen_router_config.py
SilverElixir's picture
Upload 6 files
f6f0626 verified
Raw
History Blame Contribute Delete
52.9 kB
"""
lumen_router_config.py — конфигурация моделей и логика автоматического выбора
маршрута (Gemini/OpenRouter) для одного сообщения.
Вынесено из bot.py при аудите технического долга. Всё содержимое этого файла —
конфигурационные данные (какие модели существуют, какие из них сейчас "нездоровы")
и ЧИСТЫЕ функции принятия решения о маршруте (_build_route/_or_route/_gemini_route,
эвристики "это тяжёлый запрос?"/"нужна свежая информация?") — никакого обращения
к Telegram/Gemini/OpenRouter API отсюда не происходит, поэтому этот код не зависит
от рантайм-состояния бота (в отличие от ask_gemini/ask_openrouter_*/_run_route,
которые реально выполняют маршрут и остаются в bot.py). bot.py импортирует все
нужные имена напрямую — публичные имена и поведение не изменились.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import date
from typing import Any
# Единый логгер "bot" (а не __name__ == "lumen_router_config") — намеренно,
# чтобы предупреждения из этого модуля попадали под те же тесты/фильтры логов
# (caplog.at_level(..., logger="bot")), что и остальной бот, независимо от того,
# в каком физическом файле живёт код.
log = logging.getLogger("bot")
# список моделей
# name/badge/desc/public_name/public_desc, ранее украшавшие каждую запись здесь,
# убраны целиком (ponytail-audit, июль 2026) — это были чисто отображаемые строки
# для команды /model, которая с тех пор удалена (см. "автоматический выбор модели"
# ниже); ни одно из них нигде не читалось. Настоящая модель, которую обозначает
# каждый ключ, и так понятна по самому ключу и по комментариям ниже — ничего не
# потеряно. Единственные поля, которые здесь реально используются: search_grounding/
# map_grounding/url_context/no_search/no_system/stream (см. _build_gemini_call_config)
# и quota_unconfirmed (см. _check_unconfirmed_model_quotas).
GEMINI_MODELS: dict[str, dict[str, Any]] = {
# Gemini 3.6 Flash — новый флагман линейки Flash, вышел 21 июля 2026, сменяет
# 3.5 Flash: по анонсу Google лучше в коде/агентных сценариях/мультимодальности,
# ~17% экономичнее по токенам. Контекст 1 млн токенов, знания по март 2026.
"gemini-3.6-flash": {
"stream": True,
# ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026, реальный скриншот от
# владельца): Map grounding = 0/0 (квоты нет вовсе). Search grounding в
# дашборде числится не по конкретной модели, а по общему бакету "Gemini 3"
# (объединяет 3/3.1/3.5/3.6) — и этот бакет тоже 0/0, то есть поиска нет ни
# у одной модели поколения Gemini 3.x на этом ключе (в отличие от бакета
# "Gemini 2.5" — там реально 21/1500, см. gemini-2.5-flash/lite ниже и
# обновлённый порядок GEMINI_SEARCH_CHAIN). RPD-лимит тоже подтверждён: 20/сутки.
# Прежнее консервативное предположение (0/0 по аналогии с 3.5-flash) оказалось
# верным — quota_unconfirmed снят.
"search_grounding": False, "map_grounding": False, "url_context": True,
},
# Gemini 3.5 Flash — прошлый флагман линейки Flash, сохранён в цепочке как
# резерв после 3.6 Flash.
"gemini-3.5-flash": {
"stream": True,
# По данным дашборда AI Studio (июль 2026): Map grounding для этой модели
# показывает лимит 0/0 — то есть бесплатной квоты на инструмент нет вообще
# (не "не расходовано", а именно нулевой лимит). Search grounding отдельно
# для 3.5/3-flash не выделен в дашборде (числится под "Gemini 3" с тем же 0/0) —
# отключаем оба инструмента для этой модели, чтобы не тратить попытки впустую.
# url_context — другое дело: у него нет отдельной дневной квоты в дашборде,
# он просто добавляет токены по обычной цене модели, поэтому оставляем включённым.
"search_grounding": False, "map_grounding": False, "url_context": True,
},
# Gemini 3 Flash Preview — предыдущая Preview-версия линейки Flash, сохранена
# для тех, кто предпочитает её поведение версии 3.5 (более активное обдумывание).
"gemini-3-flash-preview": {
"stream": True,
"search_grounding": False, "map_grounding": False, "url_context": True,
},
# Gemini 3.5 Flash-Lite — новая версия самой быстрой и экономичной модели,
# вышла 21 июля 2026 вместе с 3.6 Flash; превосходит 3.1 Flash-Lite в агентных
# задачах и длинном контексте, до 350 токенов/сек.
"gemini-3.5-flash-lite": {
"stream": True,
# ПОДТВЕРЖДЕНО по дашборду AI Studio (24 июля 2026). Map grounding — реально
# ненулевая квота 500/сутки, прежнее предположение (True) подтвердилось.
# Search grounding — ИСПРАВЛЕНО: раньше здесь стояло True по неверной аналогии
# с gemini-3.1-flash-lite ("раз lite-класс, значит есть квота на оба
# инструмента"). По факту дашборд считает Search grounding не по конкретной
# модели, а по общему бакету поколения — "Gemini 3" (охватывает 3/3.1/3.5/3.6
# разом) — и этот бакет 0/0. Реальная квота на поиск есть только у бакета
# "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite и обновлённый порядок
# GEMINI_SEARCH_CHAIN. quota_unconfirmed снят.
"search_grounding": False, "map_grounding": True,
},
# Gemini 3.1 Flash-Lite — прошлая версия самой быстрой и экономичной модели
# линейки, сохранена в цепочке как резерв после 3.5 Flash-Lite.
"gemini-3.1-flash-lite": {
"stream": True,
# Дашборд показывает реальную ненулевую квоту на Map grounding (0/500) для
# этой модели — map_grounding оставлен включённым. ИСПРАВЛЕНО (24 июля 2026):
# search_grounding раньше тоже стоял True — это была та же ошибка, что и у
# gemini-3.5-flash-lite ("есть квота на map grounding → значит есть и на
# search"), но дашборд считает Search grounding отдельным общим бакетом по
# ПОКОЛЕНИЮ модели ("Gemini 3" — охватывает 3/3.1/3.5/3.6 разом), и этот
# бакет показывает 0/0. Реальная квота на поиск подтверждена только у бакета
# "Gemini 2.5" (21/1500) — см. gemini-2.5-flash/lite ниже.
"search_grounding": False, "map_grounding": True,
},
# Gemini 2.5 Flash — универсальная мультимодальная модель поколения 2.5,
# хороший баланс скорости и качества для большинства повседневных задач.
"gemini-2.5-flash": {
"stream": True,
"search_grounding": True, "map_grounding": True,
},
# Gemini 2.5 Flash-Lite — экономичная модель поколения 2.5 для задач, где
# важна скорость ответа больше, чем глубина рассуждений.
"gemini-2.5-flash-lite": {
"stream": True,
"search_grounding": True, "map_grounding": True,
},
# Gemma 4 31B — флагманская открытая модель Google на 31 млрд параметров.
"gemma-4-31b-it": {
"no_system": True, "no_search": True, "stream": True,
},
# Gemma 4 26B — компактная открытая модель Google на 26 млрд параметров с
# расширенным мышлением (thinking).
"gemma-4-26b-a4b-it": {
"no_system": True,
# НАЙДЕНО при перепроверке конфига (24 июля 2026): у "родственной" модели
# gemma-4-31b-it выше стоит "no_search": True (Gemma, как открытая модель,
# не поддерживает grounding-инструменты Gemini API в принципе), а здесь этот
# флаг был случайно пропущен. Без него _build_gemini_call_config по умолчанию
# (search_grounding/url_context по умолчанию True при отсутствии ключа в конфиге)
# пытался бы добавить в запрос google_search И url_context для модели, которая
# их не поддерживает вообще — реальный риск ошибки API на КАЖДЫЙ вызов этой
# модели (она сейчас последняя в GEMINI_HEAVY_CHAIN, поэтому баг маловероятно
# проявлялся на практике, но был реальным). Добавлено для консистентности с 31B.
"no_search": True, "stream": True,
},
}
DEFAULT_GEMINI_MODEL = "gemini-3.6-flash"
# ── TTS-модели (аудит техдолга, август 2026) ──
# GEMINI_TTS_MODELS раньше был отдельным хардкодом внутри _gemini_tts_bytes в bot.py —
# второй, не связанный с GEMINI_MODELS источник правды об именах моделей Gemini. Перенесено
# сюда по тому же принципу, что и остальная конфигурация моделей.
GEMINI_TTS_MODELS: list[str] = ["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]
# Fish Audio S2.1 Pro пробуется ПЕРВОЙ в inline_tts (см. bot.py) — бесплатный доступ
# обещан провайдером только до этой даты (fish.audio/blog/s2-1-pro-free-api). Раньше
# истечение отслеживалось только комментарием в коде, без автоматической проверки — тот
# же класс пробела, из-за которого истечение tencent/hy3:free было замечено постфактум,
# а не заранее. Проверяется тем же ежесуточным циклом, что и _check_temporary_free_models_expiry.
FISH_AUDIO_TTS_MODEL = "fish-audio/s2.1-pro-free:free"
FISH_AUDIO_FREE_TIER_EXPIRY = date(2026, 8, 31)
def _check_fish_audio_tts_expiry() -> None:
today = date.today()
if today > FISH_AUDIO_FREE_TIER_EXPIRY:
log.warning(
"[tts] SYSTEM WARN: заявленный бесплатный доступ к %s истёк %s (сегодня %s) — "
"проверьте fish.audio/blog/s2-1-pro-free-api, не продлили ли снова, и обновите "
"FISH_AUDIO_FREE_TIER_EXPIRY. Если доступ действительно закрыт, _fish_audio_tts_bytes "
"в bot.py и так тихо откатывается на Gemini TTS при любой неудаче — функционально "
"ничего не сломается, но лишние неудачные запросы стоит убрать.",
FISH_AUDIO_TTS_MODEL, FISH_AUDIO_FREE_TIER_EXPIRY.isoformat(), today.isoformat(),
)
def _check_unconfirmed_model_quotas() -> None:
"""Модели, добавленные сразу после релиза (см. quota_unconfirmed=True в
GEMINI_MODELS), — их реальные RPD-лимиты и доступность search/map grounding
ещё не подтверждены по дашборду AI Studio (дашборд обновляется с задержкой
после релиза модели, иногда на несколько дней). Громко напоминаем при
каждом старте, пока флаг не снят вручную после реальной проверки — та же
идея, что и у _check_temporary_free_models_expiry выше, только для новых,
а не для истекающих моделей."""
for mid, conf in GEMINI_MODELS.items():
if conf.get("quota_unconfirmed"):
log.warning(
"[setup] SYSTEM WARN: реальные RPD-лимиты и доступность search/map grounding "
"для модели %s ещё НЕ подтверждены по дашборду AI Studio (модель недавно "
"выпущена) — текущие search_grounding/map_grounding в GEMINI_MODELS это "
"предположение по аналогии с моделью того же класса. Проверьте дашборд и "
"уберите 'quota_unconfirmed' у этой модели в bot.py, поправив конфиг при необходимости.",
mid,
)
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше здесь был словарь OPENROUTER_MODELS["text"]
# со списком dict'ов {"id", "name", "description"} на ~25 моделей — то же самое
# "name/badge/desc", что уже было вычищено из GEMINI_MODELS (см. комментарий там,
# ponytail-audit, июль 2026), но по ошибке не сделано для OpenRouter. "name"/
# "description" были чисто отображаемыми строками для команды /model, которая
# с тех пор удалена (см. README, "Автоматический выбор модели") — единственное
# реальное использование всего словаря было `[m["id"] for m in ...]`. Раз
# описания нигде не читаются, оставляем сразу плоский список ID — тот же
# TEXT_MODEL_ORDER, что раньше вычислялся ИЗ словаря, теперь и есть сам список.
#
# Список перепроверен вручную по openrouter.ai (июль 2026) — модель за моделью,
# т.к. часть ID из старого списка либо сняты с бесплатного тира (arcee-ai/trinity-
# large-thinking:free — акция закончилась 23.05, теперь платная; baidu/cobuddy:free —
# больше не бесплатна), либо заменены провайдером на новую версию (poolside/laguna-xs.2:free
# официально сворачивается в пользу laguna-xs-2.1:free). nvidia/nemotron-3.5-content-safety:free
# НАМЕРЕННО не включена — это guardrail/классификатор safe/unsafe, а не диалоговая модель,
# добавлять её сюда бессмысленно и вредно (не будет отвечать текстом на вопросы).
# ПЕРЕИМЕНОВАНО (аудит техдолга, август 2026): этот список больше НЕ используется как
# источник порядка для роутера — тот давно живёт отдельно в _OR_LIGHT_ORDER/_OR_HEAVY_ORDER/
# _OR_VISION_ORDER. Единственный оставшийся потребитель — _LEAK_LITERAL_STRINGS в
# lumen_security.py (список известных ID моделей, которые не должны дословно всплывать в
# ответе). Устаревшие/снятые с тарифа модели здесь оставлять безопасно и даже нужно — их
# ID всё ещё нельзя допускать в ответ. Старое имя TEXT_MODEL_ORDER сохранено ниже как
# алиас, чтобы не ломать импорт в lumen_security.py и внешние тесты одним махом.
_KNOWN_MODEL_IDS_FOR_LEAK_DETECTION: list[str] = [
"nvidia/nemotron-3-super-120b-a12b:free",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"openai/gpt-oss-120b:free",
"z-ai/glm-4.5-air:free",
"tencent/hy3:free",
"openrouter/owl-alpha",
"qwen/qwen3-next-80b-a3b-instruct:free",
"meta-llama/llama-3.3-70b-instruct:free",
"nousresearch/hermes-3-llama-3.1-405b:free",
"openai/gpt-oss-20b:free",
"google/gemma-4-31b-it:free",
"google/gemma-4-26b-a4b-it:free",
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
"qwen/qwen3-coder:free",
"poolside/laguna-m.1:free",
"poolside/laguna-s-2.1:free",
"poolside/laguna-xs-2.1:free",
"cohere/north-mini-code:free",
"inclusionai/ling-3.0-flash:free",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"nvidia/nemotron-nano-12b-v2-vl:free",
"nvidia/nemotron-3-nano-30b-a3b:free",
"nvidia/nemotron-nano-9b-v2:free",
"meta-llama/llama-3.2-3b-instruct:free",
"liquid/lfm-2.5-1.2b-instruct:free",
"liquid/lfm-2.5-1.2b-thinking:free",
"openrouter/free",
]
TEXT_MODEL_ORDER = _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION # алиас для обратной совместимости
# ПОПОЛНЕНО (аудит моделей, 2 августа 2026, по реальным логам продакшена + сверке
# с живым каталогом OpenRouter): добавлены poolside/laguna-s-2.1:free (новый
# средний вариант линейки Laguna, появился в каталоге в конце июля 2026 вместе с
# ling-3.0-flash) и inclusionai/ling-3.0-flash:free (самая используемая по объёму
# токенов свежедобавленная модель на дашборде владельца — 1.49T токенов/неделю,
# уступает только nemotron-3-ultra). Обе пока НЕ прогонялись через калибровочное
# сравнение с Claude Sonnet (см. историю проекта про калибровочные сессии) — качество
# и устойчивость на русском языке не подтверждены вручную, только сам факт наличия
# бесплатной квоты. См. _OR_LIGHT_ORDER ниже про фактическое место в маршруте.
# ── Единый реестр "нездоровых" моделей OpenRouter (аудит техдолга, август 2026) ──
# РАНЬШЕ это отслеживалось ТРЕМЯ независимыми механизмами: _TEMPORARY_FREE_MODELS
# (dict с датой истечения промо), _ROUTER_EXCLUDED_OR_MODELS (отдельное множество
# для ручного исключения из роутинга) и точечные комментарии в _OR_LIGHT_ORDER/
# _OR_HEAVY_ORDER о моделях, вычеркнутых оттуда вручную. Три реальных инцидента
# (tencent/hy3:free, qwen/qwen3-coder:free, qwen/qwen3-next-80b-a3b-instruct:free)
# потребовали правок в 2-3 местах каждый — ровно тот класс рассинхрона, которого
# проект и так избегает в других местах (см. TEXT_MODEL_ORDER/_next_fallback_model
# выше). Теперь один dict хранит причину/срок для каждой проблемной модели, а
# _ROUTER_EXCLUDED_OR_MODELS и предупреждение об истёкшем промо вычисляются ИЗ
# него, а не поддерживаются параллельно вручную.
@dataclass(frozen=True)
class _ModelHealthNote:
reason: str
# Задано только для ВРЕМЕННОГО промо-доступа (акция провайдера) — после этой
# даты в логи попадает предупреждение перепроверить актуальную цену на
# openrouter.ai. Модели, снятые НАВСЕГДА (не промо, а прямая инструкция
# провайдера использовать другой/платный слаг), оставляют это поле пустым —
# предупреждать об "истечении" там нечего, они просто не должны выбираться.
promo_expiry: date | None = None
_OR_MODEL_HEALTH: dict[str, _ModelHealthNote] = {
"cognitivecomputations/dolphin-mistral-24b-venice-edition:free": _ModelHealthNote(
reason="Uncensored-модель — может хуже соблюдать личность/правила Lumen. Раньше выбиралась "
"вручную только владельцем через /provider (команда удалена) — автоматический роутер "
"её не выбирает вообще."
),
"qwen/qwen3-coder:free": _ModelHealthNote(
reason="Подтверждено при аудите моделей (июль 2026): :free-эндпоинт снят провайдером.",
promo_expiry=date(2026, 6, 30),
),
"tencent/hy3:free": _ModelHealthNote(
reason="Собственная страница OpenRouter показывала 'Going away July 19, 2026' — :free-эндпоинт "
"уже снят провайдером.",
promo_expiry=date(2026, 7, 21),
),
"qwen/qwen3-next-80b-a3b-instruct:free": _ModelHealthNote(
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (25 июля 2026, ~40 минут живого трафика, 20+ "
"попыток подряд): HTTP 404 абсолютно каждый раз — 'This model is unavailable for "
"free... use this slug instead: qwen/qwen3-next-80b-a3b-instruct' (платный слаг). "
"Не временное промо, а прямая инструкция провайдера использовать другой (платный) "
"слаг — не возвращать в _OR_*_ORDER, пока провайдер вновь не откроет бесплатный "
"доступ именно к этому слагу."
),
# ── Найдено при аудите моделей 2 августа 2026 (реальные логи прода, ~5 часов
# живого трафика, 18 обработанных сообщений) ──
"z-ai/glm-4.5-air:free": _ModelHealthNote(
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (2 августа 2026, 8 попыток подряд за ~5 часов, "
"во всех — идентичная ошибка): HTTP 404 'This model is unavailable for free. The paid "
"version is available now - use this slug instead: z-ai/glm-4.5-air' — тот же самый "
"паттерн, что и у уже подтверждённых мёртвых моделей выше. Модель также отсутствует в "
"собственном 'Top Weekly free' дашборде OpenRouter владельца, хотя по историческому "
"объёму токенов должна была бы там появиться, если бы бесплатный доступ ещё "
"действовал. Раньше стояла первой в _OR_LIGHT_ORDER и третьей в _OR_HEAVY_ORDER — "
"именно она открывала цепочку почти на каждом обычном сообщении."
),
"meta-llama/llama-3.2-3b-instruct:free": _ModelHealthNote(
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (2 августа 2026, 7 попыток подряд, идентичная "
"ошибка каждый раз): HTTP 404 'This model is unavailable for free. The paid version is "
"available now - use this slug instead: meta-llama/llama-3.2-3b-instruct'. Тот же "
"провайдерский паттерн снятия с бесплатного тира, что и у llama-3.3-70b (уже "
"исключена) — Meta, судя по всему, убрала весь бесплатный тир линейки Llama целиком."
),
"liquid/lfm-2.5-1.2b-instruct:free": _ModelHealthNote(
reason="ПОДТВЕРЖДЕНО ПО РЕАЛЬНЫМ ЛОГАМ ПРОДА (2 августа 2026): 'No endpoints found for "
"liquid/lfm-2.5-1.2b-instruct:free.' — это НЕ таймаут и не перегрузка, а прямой сигнал "
"от OpenRouter, что для этого слага прямо сейчас не существует ни одного обслуживающего "
"провайдера вообще. Также отсутствует в текущем живом каталоге бесплатных моделей "
"OpenRouter (сверено отдельно от логов)."
),
"liquid/lfm-2.5-1.2b-thinking:free": _ModelHealthNote(
reason="Не поймана напрямую в логах (соседняя liquid/lfm-2.5-1.2b-instruct:free — поймана, "
"см. выше), но тоже отсутствует в текущем живом каталоге бесплатных моделей OpenRouter — "
"похоже, LiquidAI сняли оба lfm-2.5-1.2b слага с бесплатного тира одновременно. Более "
"низкая уверенность, чем у остальных записей в этом реестре — если у владельца будет "
"прямое подтверждение (успешный вызов или другая ошибка, не 'no endpoints') — эту запись "
"стоит убрать."
),
"nousresearch/hermes-3-llama-3.1-405b:free": _ModelHealthNote(
reason="Внешне подтверждено (не поймано напрямую в логах владельца — heavy-маршрут в этом "
"окне логов не запускался): независимый снимок публичного API OpenRouter от 27 июля "
"2026 явно называет эту модель в числе семи, снятых с бесплатного тира в те же девять "
"дней, что и уже независимо подтверждённые в этом же реестре llama-3.2-3b/llama-3.3-70b/"
"qwen3-coder/qwen3-next-80b/tencent-hy3/dolphin-mistral-venice — 5 из 7 моделей того "
"снимка уже были подтверждены именно этим проектом независимо, что даёт высокую "
"уверенность и в оставшихся двух (вторая — dolphin-mistral, уже была исключена по "
"другой причине выше)."
),
}
# Вычисляется ИЗ _OR_MODEL_HEALTH выше — единственное место, где решается, какие
# модели роутер не должен выбирать (см. _or_route дальше по файлу).
_ROUTER_EXCLUDED_OR_MODELS: frozenset[str] = frozenset(_OR_MODEL_HEALTH.keys())
def _check_temporary_free_models_expiry() -> None:
"""Предупреждает в логах (при каждом старте и раз в сутки, см. фоновый цикл в
_webhook_startup) про модели с истёкшим временным промо-доступом — на случай,
если запись когда-нибудь понадобится вернуть в оборот и стоит перепроверить
актуальную цену на openrouter.ai. Модели без promo_expiry (сняты навсегда, а
не по истечении акции) сюда не попадают — предупреждать об "истечении" для
них нечего."""
today = date.today()
for model_id, note in _OR_MODEL_HEALTH.items():
if note.promo_expiry is not None and today > note.promo_expiry:
log.warning(
"[or] SYSTEM WARN: временный бесплатный доступ к модели %s истёк %s (сегодня %s) — %s "
"Роутер её уже не выбирает (_ROUTER_EXCLUDED_OR_MODELS), но проверьте актуальную цену "
"на openrouter.ai, если модель когда-нибудь понадобится вернуть в оборот.",
model_id, note.promo_expiry.isoformat(), today.isoformat(), note.reason,
)
def _or_route(models: list[str]) -> list[tuple[str, str]]:
"""Превращает список ID моделей OpenRouter в список (provider, model_id) для
маршрута, попутно исключая модели из _ROUTER_EXCLUDED_OR_MODELS."""
return [("openrouter", m) for m in models if m not in _ROUTER_EXCLUDED_OR_MODELS]
def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
return [("gemini", m) for m in models]
# ── "Лёгкие"/"стандартные" запросы без вложений и ссылок — САМЫЙ ЧАСТЫЙ
# маршрут в обычном чате. Целиком обслуживается OpenRouter'ом, чтобы вообще не
# трогать скудную квоту Gemini на самом массовом классе сообщений.
#
# ВАЖНО (по итогам живого тестирования, см. историю): meta-llama/llama-3.3-70b-
# instruct:free полностью убрана из этого списка — провайдер снял её с
# бесплатного тира (HTTP 404 "This model is unavailable for free", подтверждено
# десятками идентичных отказов подряд в реальных логах). Держать её первой в
# списке означало гарантированный лишний неудачный запрос на КАЖДОЕ сообщение.
# qwen/qwen3-next-80b-a3b-instruct:free полностью УБРАНА из списка (25 июля
# 2026) — сама теперь 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS выше.
#
# ПЕРЕСТРОЕНО (25 июля 2026, по прямому сравнению ответов бота с ответами
# настоящего Claude на идентичные промпты в рамках калибровочной сессии):
# - z-ai/glm-4.5-air:free поднята на первое место — ни разу не замечена в
# порче текста ни в тяжёлом, ни в лёгком тестировании, хорошо держит русский.
# - openai/gpt-oss-20b:free ПОНИЖЕНА: подтверждено 2 тяжёлых инцидента —
# на прямой идентити-вопрос "какая ты модель на самом деле?" выдала
# бессвязную смесь языков ("Я — L accompagné.") вместо ответа, а на
# эмоционально уязвимый запрос ("меня бросила девушка, что делать") вставила
# посреди ответа нечитаемый арабский фрагмент. Не убрана совсем — на
# остальных ~6 наблюдавшихся вызовах отвечала нормально, — но с первого
# места снята однозначно.
# - nvidia/nemotron-3-nano-30b-a3b:free ПОНИЖЕНА ещё ниже: подтверждено 3
# инцидента — деванагари-мусор внутри слова ("пиिजцы" вместо "пиццы") ВМЕСТЕ
# с сырым LaTeX в ответе про площадь круга (при том что system_prompt.py
# прямо запрещает LaTeX), уверенная галлюцинация названия фильма ("К Eggman"
# вместо "Гранд Будапешт Отель"), порченые слова и выдуманное название
# компании ("Vueium") в сравнении React/Vue. Из трёх протестированных
# "лёгких" моделей — худшая по частоте порчи текста.
# Ни gpt-oss-20b, ни nemotron-3-nano-30b-a3b пока не удалены полностью: ниже
# них в цепочке стоят ЕЩЁ более мелкие модели (9B/3B/1.2B), которые в этой
# сессии не тестировались и по объёму параметров теоретически ещё менее
# надёжны на русском. Если и они дадут похожие инциденты — тогда стоит
# рассмотреть полное исключение gpt-oss-20b/nemotron-3-nano-30b-a3b, а не
# просто понижение приоритета.
#
# ИСПРАВЛЕНО (аудит моделей, 2 августа 2026, по реальным логам прода — см.
# _OR_MODEL_HEALTH выше): z-ai/glm-4.5-air:free и meta-llama/llama-3.2-3b-
# instruct:free убраны из списка вообще — обе подтверждённо отдают HTTP 404
# "unavailable for free" на 100% попыток в реальном трафике (8/8 и 7/7
# соответственно за ~5 часов). liquid/lfm-2.5-1.2b-instruct:free тоже убрана —
# "No endpoints found", то есть у слага прямо сейчас нет обслуживающего
# провайдера вообще. Раньше эти три модели стояли ПЕРВОЙ, ТРЕТЬЕЙ и ШЕСТОЙ в
# списке — на КАЖДОМ обычном сообщении бот тратил 2-3 гарантированно
# неудачные попытки (в худшем случае с реальными таймаутами, см. известный
# баг с захардкоженным 12-секундным таймаутом в _or_request в bot.py) прежде
# чем доходил до реально работающей модели. Все 8 успешных "лёгких" ответов
# в этом окне логов на самом деле отвечала nvidia/nemotron-3-nano-30b-a3b:free
# (была пятой в старом списке) — она реально работает чаще всех остальных
# кандидатов в этом списке прямо сейчас, несмотря на исторические претензии
# к качеству текста (см. её собственный комментарий ниже) — оставлена не
# первой, а третьей: доступность подтверждена свежо и много раз, но
# исторические находки о порче текста тоже не выдуманы, баланс между ними —
# решение владельца, не автоматическое повышение по одной лишь доступности.
#
# ДОБАВЛЕНА (аудит моделей, 2 августа 2026): inclusionai/ling-3.0-flash:free —
# самая используемая по объёму токенов свежедобавленная бесплатная модель на
# дашборде OpenRouter (1.49T токенов/неделю, уступает только nemotron-3-ultra
# среди ВСЕХ бесплатных моделей, включая тяжёлые) — появилась в каталоге
# в одну неделю с закрытием llama-3.2-3b/hermes-3-405b и т.п. Поставлена
# ВТОРОЙ (после самой первой попытки) — высокий трафик обычно означает
# хорошую провизию мощностей на стороне провайдера, но качество ответов на
# русском ещё НЕ проверено калибровочным сравнением с Claude Sonnet, как
# остальные модели в этом файле — стоит последить за первыми ответами.
_OR_LIGHT_ORDER: list[str] = [
"nvidia/nemotron-nano-9b-v2:free",
"inclusionai/ling-3.0-flash:free",
"nvidia/nemotron-3-nano-30b-a3b:free",
"openai/gpt-oss-20b:free",
"liquid/lfm-2.5-1.2b-thinking:free",
"openrouter/free",
]
# ── "Тяжёлые" запросы (код, многошаговые рассуждения, объёмный анализ) без
# нужды в интернете/медиа — тоже сначала к OpenRouter: среди бесплатных
# моделей там есть по-настоящему сильные кандидаты (120B/550B), не уступающие
# по мощи флагману Gemini, но не занимающие его 20 запросов/сутки.
#
# qwen/qwen3-next-80b-a3b-instruct:free убрана из запасного места в конце —
# 404 на каждый вызов, см. _ROUTER_EXCLUDED_OR_MODELS. Заменена на дополнительный
# резерв glm-4.5-air (уже есть выше в цепочке, но openrouter/free как последний
# универсальный fallback остаётся).
#
# МОНИТОРИНГ (25 июля 2026): nvidia/nemotron-3-super-120b-a12b:free, несмотря на
# статус флагмана этого тира, дала 1 инцидент из 4 протестированных тяжёлых
# запросов — в ответе про TCP/IP посреди русского текста встретился китайский
# иероглиф "尾部" (вместо "хвост"), итальянское "infine" и английское "preventing".
# Остальные 3 запроса (Rust-палиндром, Python-сортировка, сравнение iPhone/Samsung)
# отработала чисто. Пока не понижаем — один инцидент на четыре успешных попытки
# не повод убирать флагмана, но стоит присматривать за логами `[stream]`/ответами
# этой модели и понизить её, если порча текста повторится.
# ИСПРАВЛЕНО (аудит моделей, 2 августа 2026): z-ai/glm-4.5-air:free убрана (см.
# _OR_MODEL_HEALTH — подтверждённо мертва, 8/8 попыток 404 в реальных логах).
# nousresearch/hermes-3-llama-3.1-405b:free убрана (внешне подтверждённый снос
# с бесплатного тира той же волной, что и уже независимо подтверждённые в этом
# проекте llama-3.2-3b/hy3/qwen3-coder/qwen3-next-80b — см. reason в реестре).
_OR_HEAVY_ORDER: list[str] = [
"nvidia/nemotron-3-super-120b-a12b:free",
"openai/gpt-oss-120b:free",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"openrouter/free",
]
# ── Вложение (изображение) без нужды в свежей информации — у OpenRouter
# достаточно бесплатных vision-моделей, чтобы не трогать Gemini. OpenRouter
# физически принимает только изображения (base64 data URL) — для видео/аудио
# этот список не используется вообще, см. _build_route/_run_route ниже.
_OR_VISION_ORDER: list[str] = [
"nvidia/nemotron-nano-12b-v2-vl:free",
"google/gemma-4-31b-it:free",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"google/gemma-4-26b-a4b-it:free",
]
# ── Цепочки Gemini. GEMINI_HEAVY_CHAIN — от сильной модели к слабой (тот же
# состав/порядок, что был у прежнего единственного quota_fallback_chain), для
# случаев, где ТРЕБУЕТСЯ именно Gemini (YouTube/сайт по ссылке, видео/аудио
# вложение), но живой поиск не нужен. GEMINI_SEARCH_CHAIN — те же модели, но
# начиная с тех, у кого по дашборду AI Studio реально ЕСТЬ квота на search
# grounding (флагман 3.5 и 3-preview её не имеют вообще — см. комментарии в
# GEMINI_MODELS выше), чтобы запрос, которому нужен живой поиск, не попадал
# первым делом на модель, что физически не может искать.
GEMINI_HEAVY_CHAIN: list[str] = [
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3-flash-preview",
"gemini-3.5-flash-lite",
"gemini-3.1-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemma-4-31b-it",
"gemma-4-26b-a4b-it",
]
# ПЕРЕСТРОЕНО (24 июля 2026, по реальным данным дашборда AI Studio): раньше первыми
# здесь стояли gemini-3.5-flash-lite/gemini-3.1-flash-lite в предположении, что у
# них есть search grounding — это оказалось неверно (см. комментарии в GEMINI_MODELS
# выше). Дашборд считает квоту на Search grounding не по конкретной модели, а по
# общему бакету ПОКОЛЕНИЯ: бакет "Gemini 3" (охватывает 3/3.1/3.5/3.6 целиком) — 0/0,
# реальной квоты на поиск нет вовсе ни у одной модели линейки Gemini 3.x. Бакет
# "Gemini 2.5" — 21/1500, то есть поиск реально работает ТОЛЬКО у gemini-2.5-flash и
# gemini-2.5-flash-lite. Они теперь и стоят первыми для запросов, где нужна живая
# информация. Модели Gemini 3.x оставлены в цепочке как резерв — не смогут вызвать
# google_search, но всё ещё могут ответить по своим знаниям (и через url_context,
# если в тексте есть ссылка — та возможность отдельной квоты не имеет вовсе).
GEMINI_SEARCH_CHAIN: list[str] = [
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.5-flash-lite",
"gemini-3.1-flash-lite",
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3-flash-preview",
]
# Совпадает по составу с прежним quota_fallback_chain — используется как дефолт,
# если ask_gemini вызвана без явной цепочки (например, напрямую из теста).
GEMINI_DEFAULT_CHAIN: list[str] = GEMINI_HEAVY_CHAIN
# Только "полноценные" (не no_system/Gemma) модели умеют читать сайты по ссылке
# (url_context) и разбирать YouTube-видео по ссылке (file_uri) — то же
# ограничение, что раньше проверялось в _handle_message_core через
# current_gemini_conf.get("no_system").
GEMINI_LINK_CHAIN: list[str] = [m for m in GEMINI_HEAVY_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")]
GEMINI_LINK_SEARCH_CHAIN: list[str] = [m for m in GEMINI_SEARCH_CHAIN if not GEMINI_MODELS.get(m, {}).get("no_system")]
# ── Эвристика "это сложный/тяжёлый запрос?" — без обращения к LLM. Ложные
# срабатывания недороги: худший случай — используется чуть более мощная
# модель, чем реально нужно, а не отказ в ответе.
_HEAVY_QUERY_RE = re.compile(
r"напиши\s+(код|функци\w*|скрипт|программ\w*|класс\w*|запрос\s+sql|regex|регуляр\w*)"
r"|сгенерируй\s+код|исправь\s+(код|баг|ошибк\w*)|отрефактор\w*|рефактор\w*|оптимизируй"
r"|напиши\s+(эссе|статью|доклад|реферат|сочинение|резюме|cv)\b"
r"|проанализируй\w*|разбер(и|ём)\s+подробно|объясни\s+подробно"
r"|сравни\s+.{0,40}(и|с)\s+|докажи\b|доказательство"
r"|реши\s+(задач\w*|уравнени\w*|систем\w*)"
r"|составь\s+(план|таблиц\w*|список\s+из)"
r"|многошагов\w*|пошагов\w*\s+(инструкц\w*|план\w*)"
r"|архитектур\w*|алгоритм\w*",
re.IGNORECASE,
)
def _looks_like_heavy_query(text: str) -> bool:
"""Грубая эвристика "это тяжёлый запрос (код/анализ/многошаговые рассуждения)?"
Намеренно консервативная (без вызова LLM — см. комментарий в начале секции)."""
if not text:
return False
if "```" in text or len(text) > 600:
return True
if text.count("?") >= 3:
return True
return bool(_HEAVY_QUERY_RE.search(text))
# ── Эвристика "нужна ли живая информация из интернета?" Ложные срабатывания
# тоже недороги: худший случай — маршрут отдаёт предпочтение search-способной
# модели там, где поиск был не нужен, но модель сама решает, вызывать ли его.
_FRESHNESS_QUERY_RE = re.compile(
r"сейчас|сегодня|текущ\w*|последн\w*|актуальн\w*|свеж\w*|недавно|на\s+данный\s+момент"
r"|новост\w*|курс\s+(валют|доллара|евро|рубл\w*)|погод\w*"
r"|цена\w*|стоимост\w*|сколько\s+стоит"
r"|кто\s+(сейчас|является|президент|премьер|глава|ceo|мэр)"
r"|результат\w*\s+(матч\w*|игр\w*|выбор\w*)"
r"|в\s+эт(ом|ой)\s+(году|месяце|неделе)"
r"|\b202[6-9]\b",
re.IGNORECASE,
)
def _looks_like_freshness_query(text: str) -> bool:
return bool(text) and bool(_FRESHNESS_QUERY_RE.search(text))
def _build_route(
*, needs_youtube: bool, needs_website: bool, media_mime: str | None,
is_heavy: bool, needs_freshness: bool,
) -> list[tuple[str, str]]:
"""Строит приоритетный список кандидатов (provider, model_id) для текущего
сообщения — НЕПУСТОЙ список, первый элемент пробуется первым (см. _run_route).
Порядок кандидатов внутри одного провайдера — по возрастанию "дороговизны"
для дефицитной квоты, а не по итоговому качеству ответа отдельно взятой модели."""
is_video_or_audio_media = bool(media_mime) and not media_mime.startswith("image/")
if needs_youtube or needs_website:
# Только Gemini умеет читать сайты по ссылке и разбирать YouTube-видео —
# у OpenRouter в этом маршруте вообще нет места, эскалировать некуда.
chain = GEMINI_LINK_SEARCH_CHAIN if needs_freshness else GEMINI_LINK_CHAIN
return _gemini_route(chain)
if media_mime:
if needs_freshness or is_video_or_audio_media:
# Видео/аудио вложение ИЛИ нужен живой поиск вместе с медиа — может
# только Gemini (OpenRouter физически не примет не-изображение, и
# ни одна его модель не имеет доступа к поиску).
chain = GEMINI_SEARCH_CHAIN if needs_freshness else GEMINI_HEAVY_CHAIN
return _gemini_route(chain)
# Изображение без нужды в поиске — сначала бесплатные vision-модели
# OpenRouter, Gemini — резерв, если они все разом откажут.
return _or_route(_OR_VISION_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
if needs_freshness:
# Текст без вложений, но нужна свежая информация — только у Gemini
# реально есть поиск; OpenRouter в конце как резерв на случай, если
# Gemini исчерпан целиком (без поиска, но хоть какой-то ответ).
return _gemini_route(GEMINI_SEARCH_CHAIN) + _or_route(_OR_HEAVY_ORDER if is_heavy else _OR_LIGHT_ORDER)
# Основной случай: обычный текст без вложений/ссылок/признаков нужды в
# интернете — целиком к OpenRouter, Gemini — резерв на случай отказа всей
# цепочки OpenRouter разом.
if is_heavy:
return _or_route(_OR_HEAVY_ORDER) + _gemini_route(GEMINI_HEAVY_CHAIN)
return _or_route(_OR_LIGHT_ORDER) + _gemini_route(GEMINI_SEARCH_CHAIN)