Spaces:
Running
Running
SilverElixir commited on
Commit ·
3d792d6
1
Parent(s): 4ab6cec
P2: extract lumen_errors.py (model error classification/texts, system prompt) with bot.X re-exports (AUD-A-001)
Browse files- bot.py +25 -130
- lumen_errors.py +147 -0
bot.py
CHANGED
|
@@ -23,7 +23,7 @@ import tempfile
|
|
| 23 |
import time
|
| 24 |
from pathlib import Path
|
| 25 |
from collections import deque
|
| 26 |
-
from datetime import date
|
| 27 |
from typing import Any
|
| 28 |
import urllib.request as _urllib_request
|
| 29 |
|
|
@@ -46,8 +46,6 @@ from aiogram.types import (
|
|
| 46 |
from google import genai
|
| 47 |
from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
|
| 48 |
|
| 49 |
-
from system_prompt import SYSTEM_PROMPT
|
| 50 |
-
|
| 51 |
# язык системных сообщений бота (см. lumen_lang.py): фиксированные строки,
|
| 52 |
# которые бот отправляет сам (/start, подсказки, ошибки, статусы). Ответы ИИ
|
| 53 |
# не трогаем — модель отвечает на языке собеседника.
|
|
@@ -983,108 +981,6 @@ async def _edit_message_quietly(msg: Message | None, text: str, **kwargs: Any) -
|
|
| 983 |
|
| 984 |
# разбор ошибок
|
| 985 |
|
| 986 |
-
def _error_text(e: Exception) -> str:
|
| 987 |
-
return " ".join(p for p in [str(e), str(getattr(e, "message", "")), str(getattr(e, "detail", ""))] if p).strip()
|
| 988 |
-
|
| 989 |
-
def _error_status(e: Exception, text: str) -> int | None:
|
| 990 |
-
for a in ("status_code", "status", "code", "http_status"):
|
| 991 |
-
val = getattr(e, a, None)
|
| 992 |
-
try:
|
| 993 |
-
if val is not None:
|
| 994 |
-
return int(val)
|
| 995 |
-
except Exception:
|
| 996 |
-
pass
|
| 997 |
-
m = re.search(r"(?<!\d)(\d{3})(?!\d)", text)
|
| 998 |
-
if m:
|
| 999 |
-
try:
|
| 1000 |
-
return int(m.group(1))
|
| 1001 |
-
except Exception:
|
| 1002 |
-
pass
|
| 1003 |
-
return None
|
| 1004 |
-
|
| 1005 |
-
def _classify_model_error(status: int | None, text: str) -> str:
|
| 1006 |
-
low = text.lower()
|
| 1007 |
-
if status == 429 or any(tok in low for tok in ("resource_exhausted", "too many requests", "rate limit", "quota", "лимит")):
|
| 1008 |
-
return "rate_limit"
|
| 1009 |
-
if status == 402 or any(tok in low for tok in ("payment required", "paid tier", "requires paid", "billing", "кредит")):
|
| 1010 |
-
return "paid"
|
| 1011 |
-
if status in {401, 403} or any(tok in low for tok in ("unauthorized", "forbidden", "permission", "blocked")):
|
| 1012 |
-
return "forbidden"
|
| 1013 |
-
if status in {400, 404} or any(tok in low for tok in ("not found", "invalid argument", "invalid model", "unsupported", "unavailable")):
|
| 1014 |
-
return "unavailable"
|
| 1015 |
-
return "other"
|
| 1016 |
-
|
| 1017 |
-
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше _or_error_msg и _gemini_error_msg держали
|
| 1018 |
-
# почти идентичную классификацию (rate_limit/paid/forbidden/unavailable) в двух
|
| 1019 |
-
# отдельных функциях с чуть разным текстом — риск, что при будущей правке кто-то
|
| 1020 |
-
# поправит формулировку в одной и забудет про другую (ровно то дублирование,
|
| 1021 |
-
# которого проект и так избегает в других местах, см. _next_fallback_model выше).
|
| 1022 |
-
# Общий источник текста для обеих — эта таблица; провайдер-специфичной разницы
|
| 1023 |
-
# в тексте больше нет и намеренно: сообщение об ошибке НЕ должно называть
|
| 1024 |
-
# "резервного провайдера" — с автоматическим роутером (см. "автоматический выбор
|
| 1025 |
-
# модели" ниже) OpenRouter сплошь и рядом оказывается ПЕРВЫМ, а не резервным
|
| 1026 |
-
# кандидатом, так что старая формулировка была не просто лишней деталью
|
| 1027 |
-
# реализации, а фактически неверной. Упоминания команд /model и /provider тоже
|
| 1028 |
-
# убраны целиком — обе команды удалены (см. README, "Автоматический выбор
|
| 1029 |
-
# модели"), реального способа переключиться вручную больше нет, и предлагать
|
| 1030 |
-
# его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
|
| 1031 |
-
# ВАЖНО для формулировок: Lumen подаётся как единая модель (см. ИДЕНТИЧНОСТЬ
|
| 1032 |
-
# в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
|
| 1033 |
-
# числе или "подбор другой модели": для пользователя есть только Lumen, а
|
| 1034 |
-
# переключения внутри — деталь реализации.
|
| 1035 |
-
# Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — тонкая
|
| 1036 |
-
# обёртка с языком (lang="en" — дефолт для вызовов без чата, в тестах в том числе).
|
| 1037 |
-
_MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
|
| 1038 |
-
|
| 1039 |
-
def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
|
| 1040 |
-
key = {
|
| 1041 |
-
"rate_limit": "model_err_rate_limit",
|
| 1042 |
-
"paid": "model_err_paid",
|
| 1043 |
-
"forbidden": "model_err_forbidden",
|
| 1044 |
-
"unavailable": "model_err_unavailable",
|
| 1045 |
-
}.get(kind, "model_err_fallback")
|
| 1046 |
-
return _lang_t(lang, key)
|
| 1047 |
-
|
| 1048 |
-
def _or_error_msg(e: Exception, kind: str, lang: str = DEFAULT_LANG) -> str:
|
| 1049 |
-
# Сырой текст ошибки API сюда намеренно не подставляется (может содержать
|
| 1050 |
-
# внутренние детали инфраструктуры, HTML/JSON или обрывки заголовков) —
|
| 1051 |
-
# то же правило, что уже применяется в _gemini_error_msg ниже.
|
| 1052 |
-
txt = _error_text(e).strip() or e.__class__.__name__
|
| 1053 |
-
status = _error_status(e, txt)
|
| 1054 |
-
return _model_error_text(_classify_model_error(status, txt), lang)
|
| 1055 |
-
|
| 1056 |
-
class GeminiAllModelsExhaustedError(RuntimeError):
|
| 1057 |
-
"""Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей
|
| 1058 |
-
из цепочки фоллбека — то есть реально весь бесплатный лимит API-ключа исчерпан,
|
| 1059 |
-
а не просто конкретная модель временно занята."""
|
| 1060 |
-
def __init__(self, exhausted_models: list[str]) -> None:
|
| 1061 |
-
self.exhausted_models = exhausted_models
|
| 1062 |
-
super().__init__(f"All Gemini models exhausted quota: {', '.join(exhausted_models)}")
|
| 1063 |
-
|
| 1064 |
-
def _next_fallback_model(tried_models: set[str], chain: list[str]) -> str | None:
|
| 1065 |
-
"""Возвращает первую ещё не испробованную модель из quota_fallback_chain.
|
| 1066 |
-
Вынесено в отдельную функцию, т.к. одна и та же проверка используется в
|
| 1067 |
-
ask_gemini сразу в трёх ветках (429, timeout, 503/500) — дублирование трёх
|
| 1068 |
-
identичных генераторов раньше создавало риск, что при будущей правке кто-то
|
| 1069 |
-
поправит один из трёх вызовов и забудет остальные."""
|
| 1070 |
-
return next((m for m in chain if m not in tried_models), None)
|
| 1071 |
-
|
| 1072 |
-
def _gemini_error_msg(e: Exception, model_id: str, lang: str = DEFAULT_LANG) -> str:
|
| 1073 |
-
if isinstance(e, ValueError):
|
| 1074 |
-
return str(e)
|
| 1075 |
-
if isinstance(e, GeminiAllModelsExhaustedError):
|
| 1076 |
-
return _lang_t(lang, "err_quota_exhausted")
|
| 1077 |
-
txt = _error_text(e).strip() or e.__class__.__name__
|
| 1078 |
-
status = _error_status(e, txt)
|
| 1079 |
-
kind = _classify_model_error(status, txt)
|
| 1080 |
-
log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status)
|
| 1081 |
-
# Реальное имя модели (например "Gemini 3.5 Flash") сюда намеренно не
|
| 1082 |
-
# подставляется — в сообщении об ошибке посреди обычного диалога это
|
| 1083 |
-
# выглядело бы как случайная утечка бренда/вендора (см. защиту от утечки
|
| 1084 |
-
# идентичности ниже). Не показываем и сырой ответ API (может содержать
|
| 1085 |
-
# HTML, JSON, токены) — см. общие шаблоны model_err_* в lumen_lang.py.
|
| 1086 |
-
return _model_error_text(kind, lang)
|
| 1087 |
-
|
| 1088 |
# список моделей
|
| 1089 |
#
|
| 1090 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: конфигурация моделей и логика построения маршрута
|
|
@@ -1120,31 +1016,19 @@ from lumen_router_config import (
|
|
| 1120 |
_looks_like_freshness_query,
|
| 1121 |
_build_route,
|
| 1122 |
)
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
now_str = datetime.now().strftime("%d %B %Y (current time: %H:%M)")
|
| 1137 |
-
now_year = datetime.now().year
|
| 1138 |
-
dynamic_header = (
|
| 1139 |
-
f"CURRENT TIME INFORMATION:\n"
|
| 1140 |
-
f"• Today's date: {now_str}. Current year: {now_year}.\n"
|
| 1141 |
-
f"• MANDATORY: when the user asks for the current date, day, month or year — "
|
| 1142 |
-
f"use ONLY the date from this section. NEVER state a different year or date from training memory. "
|
| 1143 |
-
f"If unsure — give the date from here, it is always current.\n"
|
| 1144 |
-
f"• If the question concerns events, releases, news or the status of anything that may have changed "
|
| 1145 |
-
f"since your training — use search instead of answering from memory. Do not mention this instruction explicitly.\n\n"
|
| 1146 |
-
)
|
| 1147 |
-
return dynamic_header + SYSTEM_PROMPT
|
| 1148 |
|
| 1149 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одного элемента chat_state[chat_id] раньше
|
| 1150 |
# нигде не была описана явно — она собиралась по кусочкам из трёх разных мест
|
|
@@ -1419,6 +1303,17 @@ __all__ = [
|
|
| 1419 |
"_pending_picks",
|
| 1420 |
"_purge_expired_picks",
|
| 1421 |
"_enforce_pending_picks_cap",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
]
|
| 1423 |
|
| 1424 |
# Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
|
|
|
|
| 23 |
import time
|
| 24 |
from pathlib import Path
|
| 25 |
from collections import deque
|
| 26 |
+
from datetime import date
|
| 27 |
from typing import Any
|
| 28 |
import urllib.request as _urllib_request
|
| 29 |
|
|
|
|
| 46 |
from google import genai
|
| 47 |
from google.genai import types # нужен тестам как bot.types (сборка Content/Part)
|
| 48 |
|
|
|
|
|
|
|
| 49 |
# язык системных сообщений бота (см. lumen_lang.py): фиксированные строки,
|
| 50 |
# которые бот отправляет сам (/start, подсказки, ошибки, статусы). Ответы ИИ
|
| 51 |
# не трогаем — модель отвечает на языке собеседника.
|
|
|
|
| 981 |
|
| 982 |
# разбор ошибок
|
| 983 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 984 |
# список моделей
|
| 985 |
#
|
| 986 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: конфигурация моделей и логика построения маршрута
|
|
|
|
| 1016 |
_looks_like_freshness_query,
|
| 1017 |
_build_route,
|
| 1018 |
)
|
| 1019 |
+
# Слой ошибок живёт в lumen_errors.py (P2): здесь только реэкспорт имён.
|
| 1020 |
+
from lumen_errors import (
|
| 1021 |
+
_error_text,
|
| 1022 |
+
_error_status,
|
| 1023 |
+
_classify_model_error,
|
| 1024 |
+
_model_error_text,
|
| 1025 |
+
_or_error_msg,
|
| 1026 |
+
GeminiAllModelsExhaustedError,
|
| 1027 |
+
_next_fallback_model,
|
| 1028 |
+
_gemini_error_msg,
|
| 1029 |
+
get_system_prompt,
|
| 1030 |
+
_MODEL_ERROR_FALLBACK_MSG,
|
| 1031 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
|
| 1033 |
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: форма одного элемента chat_state[chat_id] раньше
|
| 1034 |
# нигде не была описана явно — она собиралась по кусочкам из трёх разных мест
|
|
|
|
| 1303 |
"_pending_picks",
|
| 1304 |
"_purge_expired_picks",
|
| 1305 |
"_enforce_pending_picks_cap",
|
| 1306 |
+
# Имена из lumen_errors.py код bot.py сам не читает — только `bot.X` в тестах.
|
| 1307 |
+
"_error_text",
|
| 1308 |
+
"_error_status",
|
| 1309 |
+
"_classify_model_error",
|
| 1310 |
+
"_model_error_text",
|
| 1311 |
+
"_or_error_msg",
|
| 1312 |
+
"GeminiAllModelsExhaustedError",
|
| 1313 |
+
"_next_fallback_model",
|
| 1314 |
+
"_gemini_error_msg",
|
| 1315 |
+
"get_system_prompt",
|
| 1316 |
+
"_MODEL_ERROR_FALLBACK_MSG",
|
| 1317 |
]
|
| 1318 |
|
| 1319 |
# Троттлинг для уведомления владельца о полном исчерпании квоты Gemini (см.
|
lumen_errors.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_errors.py — разбор ошибок моделей и тексты для пользователя
|
| 3 |
+
(вынесено из bot.py, P2 аудита): классификация rate_limit/paid/forbidden,
|
| 4 |
+
локализованные шаблоны, фолбэк-цепочка, промпт с датой.
|
| 5 |
+
|
| 6 |
+
Связи с рантаймом bot.py — только через отложенный `import bot` внутри функций.
|
| 7 |
+
bot.py реэкспортирует имена — `bot._model_error_text` и т.п. в тестах
|
| 8 |
+
и роутах не менялись.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import re
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
from lumen_lang import DEFAULT_LANG, t as _lang_t
|
| 17 |
+
from system_prompt import SYSTEM_PROMPT
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger("bot")
|
| 20 |
+
|
| 21 |
+
def _error_text(e: Exception) -> str:
|
| 22 |
+
return " ".join(p for p in [str(e), str(getattr(e, "message", "")), str(getattr(e, "detail", ""))] if p).strip()
|
| 23 |
+
|
| 24 |
+
def _error_status(e: Exception, text: str) -> int | None:
|
| 25 |
+
for a in ("status_code", "status", "code", "http_status"):
|
| 26 |
+
val = getattr(e, a, None)
|
| 27 |
+
try:
|
| 28 |
+
if val is not None:
|
| 29 |
+
return int(val)
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
m = re.search(r"(?<!\d)(\d{3})(?!\d)", text)
|
| 33 |
+
if m:
|
| 34 |
+
try:
|
| 35 |
+
return int(m.group(1))
|
| 36 |
+
except Exception:
|
| 37 |
+
pass
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
def _classify_model_error(status: int | None, text: str) -> str:
|
| 41 |
+
low = text.lower()
|
| 42 |
+
if status == 429 or any(tok in low for tok in ("resource_exhausted", "too many requests", "rate limit", "quota", "лимит")):
|
| 43 |
+
return "rate_limit"
|
| 44 |
+
if status == 402 or any(tok in low for tok in ("payment required", "paid tier", "requires paid", "billing", "кредит")):
|
| 45 |
+
return "paid"
|
| 46 |
+
if status in {401, 403} or any(tok in low for tok in ("unauthorized", "forbidden", "permission", "blocked")):
|
| 47 |
+
return "forbidden"
|
| 48 |
+
if status in {400, 404} or any(tok in low for tok in ("not found", "invalid argument", "invalid model", "unsupported", "unavailable")):
|
| 49 |
+
return "unavailable"
|
| 50 |
+
return "other"
|
| 51 |
+
|
| 52 |
+
# НАЙДЕНО ПРИ АУДИТЕ ТЕХДОЛГА: раньше _or_error_msg и _gemini_error_msg держали
|
| 53 |
+
# почти идентичную классификацию (rate_limit/paid/forbidden/unavailable) в двух
|
| 54 |
+
# отдельных функциях с чуть разным текстом — риск, что при будущей правке кто-то
|
| 55 |
+
# поправит формулировку в одной и забудет про другую (ровно то дублирование,
|
| 56 |
+
# которого проект и так избегает в других местах, см. _next_fallback_model выше).
|
| 57 |
+
# Общий источник текста для обеих — эта таблица; провайдер-специфичной разницы
|
| 58 |
+
# в тексте больше нет и намеренно: сообщение об ошибке НЕ должно называть
|
| 59 |
+
# "резервного провайдера" — с автоматическим роутером (см. "автоматический выбор
|
| 60 |
+
# модели" ниже) OpenRouter сплошь и рядом оказывается ПЕРВЫМ, а не резервным
|
| 61 |
+
# кандидатом, так что старая формулировка была не просто лишней деталью
|
| 62 |
+
# реализации, а фактически неверной. Упоминания команд /model и /provider тоже
|
| 63 |
+
# убраны целиком — обе команды удалены (см. README, "Автоматический выбор
|
| 64 |
+
# модели"), реального способа переключиться вручную больше нет, и предлагать
|
| 65 |
+
# его пользователю было прямой (и активно вводящей в заблуждение) ошибкой.
|
| 66 |
+
# ВАЖНО для формулировок: Lumen подаётся как единая модель (см. ИДЕНТИЧНОСТЬ
|
| 67 |
+
# в system_prompt.py) — тексты НЕ должны упоминать "модели" во множественном
|
| 68 |
+
# числе или "подбор другой модели": для пользователя есть только Lumen, а
|
| 69 |
+
# переключения внутри — деталь реализации.
|
| 70 |
+
# Сами тексты живут в lumen_lang.py (ключи model_err_*), здесь — тонкая
|
| 71 |
+
# обёртка с языком (lang="en" — дефолт для вызовов без чата, в тестах в том числе).
|
| 72 |
+
_MODEL_ERROR_FALLBACK_MSG = _lang_t(DEFAULT_LANG, "model_err_fallback")
|
| 73 |
+
|
| 74 |
+
def _model_error_text(kind: str, lang: str = DEFAULT_LANG) -> str:
|
| 75 |
+
key = {
|
| 76 |
+
"rate_limit": "model_err_rate_limit",
|
| 77 |
+
"paid": "model_err_paid",
|
| 78 |
+
"forbidden": "model_err_forbidden",
|
| 79 |
+
"unavailable": "model_err_unavailable",
|
| 80 |
+
}.get(kind, "model_err_fallback")
|
| 81 |
+
return _lang_t(lang, key)
|
| 82 |
+
|
| 83 |
+
def _or_error_msg(e: Exception, kind: str, lang: str = DEFAULT_LANG) -> str:
|
| 84 |
+
# Сырой текст ошибки API сюда намеренно не подставляется (может содержать
|
| 85 |
+
# внутренние детали инфраструктуры, HTML/JSON или обрывки заголовков) —
|
| 86 |
+
# то же правило, что уже применяется в _gemini_error_msg ниже.
|
| 87 |
+
txt = _error_text(e).strip() or e.__class__.__name__
|
| 88 |
+
status = _error_status(e, txt)
|
| 89 |
+
return _model_error_text(_classify_model_error(status, txt), lang)
|
| 90 |
+
|
| 91 |
+
class GeminiAllModelsExhaustedError(RuntimeError):
|
| 92 |
+
"""Поднимается, когда 429/RESOURCE_EXHAUSTED получен подряд от всех моделей
|
| 93 |
+
из цепочки фоллбека — то есть реально весь бесплатный лимит API-ключа исчерпан,
|
| 94 |
+
а не просто конкретная модель временно занята."""
|
| 95 |
+
def __init__(self, exhausted_models: list[str]) -> None:
|
| 96 |
+
self.exhausted_models = exhausted_models
|
| 97 |
+
super().__init__(f"All Gemini models exhausted quota: {', '.join(exhausted_models)}")
|
| 98 |
+
|
| 99 |
+
def _next_fallback_model(tried_models: set[str], chain: list[str]) -> str | None:
|
| 100 |
+
"""Возвращает первую ещё не испробованную модель из quota_fallback_chain.
|
| 101 |
+
Вынесено в отдельную функцию, т.к. одна и та же проверка используется в
|
| 102 |
+
ask_gemini сразу в трёх ветках (429, timeout, 503/500) — дублирование трёх
|
| 103 |
+
identичных генераторов раньше создавало риск, что при будущей правке кто-то
|
| 104 |
+
поправит один из трёх вызовов и забудет остальные."""
|
| 105 |
+
return next((m for m in chain if m not in tried_models), None)
|
| 106 |
+
|
| 107 |
+
def _gemini_error_msg(e: Exception, model_id: str, lang: str = DEFAULT_LANG) -> str:
|
| 108 |
+
if isinstance(e, ValueError):
|
| 109 |
+
return str(e)
|
| 110 |
+
if isinstance(e, GeminiAllModelsExhaustedError):
|
| 111 |
+
return _lang_t(lang, "err_quota_exhausted")
|
| 112 |
+
txt = _error_text(e).strip() or e.__class__.__name__
|
| 113 |
+
status = _error_status(e, txt)
|
| 114 |
+
kind = _classify_model_error(status, txt)
|
| 115 |
+
log.debug("[gemini] _gemini_error_msg: model=%s kind=%s status=%s", model_id, kind, status)
|
| 116 |
+
# Реальное имя модели (например "Gemini 3.5 Flash") сюда намеренно не
|
| 117 |
+
# подставляется — в сообщении об ошибке посреди обычного диалога это
|
| 118 |
+
# выглядело бы как случайная утечка бренда/вендора (см. защиту от утечки
|
| 119 |
+
# идентичности ниже). Не показываем и сырой ответ API (может содержать
|
| 120 |
+
# HTML, JSON, токены) — см. общие шаблоны model_err_* в lumen_lang.py.
|
| 121 |
+
return _model_error_text(kind, lang)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def get_system_prompt(model_id: str | None = None) -> str:
|
| 126 |
+
# НАЙДЕНО ПРИ КОД-РЕВЬЮ (28 августа 2026): `model_id` нигде в теле функции не
|
| 127 |
+
# читается — все вызывающие места (ask_gemini, _build_gemini_call_config,
|
| 128 |
+
# ask_openrouter_text/multimodal) получают ОДНУ И ТУ ЖЕ строку независимо от
|
| 129 |
+
# переданной модели, и это осознанно (см. комментарий у _build_gemini_call_config
|
| 130 |
+
# про единый источник правды для system_instruction и фейкового identity-обмена
|
| 131 |
+
# Gemma — расхождение промпта между моделями было бы источником трудноуловимых
|
| 132 |
+
# багов). Параметр оставлен в сигнатуре как заранее готовая точка расширения на
|
| 133 |
+
# случай, если когда-нибудь понадобится реальная per-model кастомизация — но
|
| 134 |
+
# прямо сейчас это не мёртвый код по ошибке, а сознательное решение "одна и та
|
| 135 |
+
# же строка для всех".
|
| 136 |
+
now_str = datetime.now().strftime("%d %B %Y (current time: %H:%M)")
|
| 137 |
+
now_year = datetime.now().year
|
| 138 |
+
dynamic_header = (
|
| 139 |
+
f"CURRENT TIME INFORMATION:\n"
|
| 140 |
+
f"• Today's date: {now_str}. Current year: {now_year}.\n"
|
| 141 |
+
f"• MANDATORY: when the user asks for the current date, day, month or year — "
|
| 142 |
+
f"use ONLY the date from this section. NEVER state a different year or date from training memory. "
|
| 143 |
+
f"If unsure — give the date from here, it is always current.\n"
|
| 144 |
+
f"• If the question concerns events, releases, news or the status of anything that may have changed "
|
| 145 |
+
f"since your training — use search instead of answering from memory. Do not mention this instruction explicitly.\n\n"
|
| 146 |
+
)
|
| 147 |
+
return dynamic_header + SYSTEM_PROMPT
|