Spaces:
Running
Running
SilverElixir commited on
Commit ·
b5edc97
1
Parent(s): 9aa928f
P2: extract lumen_streaming.py (dots, pieces, providers, run_streaming_reply, try_* wrappers) with bot.X re-exports (AUD-A-001)
Browse files- bot.py +26 -415
- lumen_streaming.py +462 -0
bot.py
CHANGED
|
@@ -1292,6 +1292,21 @@ __all__ = [
|
|
| 1292 |
"webhook_handler",
|
| 1293 |
"network_diagnostics",
|
| 1294 |
"export_state",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1295 |
]
|
| 1296 |
|
| 1297 |
def _upstash_request(command_path: str, *, method: str = "GET", body: bytes | None = None) -> Any:
|
|
@@ -2016,11 +2031,8 @@ def _record_quota_usage(provider: str, model_id: str) -> None:
|
|
| 2016 |
# составляющие их `_LEAK_BRAND_TOKENS`/`_LEAK_LITERAL_STRINGS`) нужны только
|
| 2017 |
# самим детекторам внутри lumen_security.py, а не коду bot.py.
|
| 2018 |
from lumen_security import (
|
| 2019 |
-
_leak_scan_window,
|
| 2020 |
_IDENTITY_LEAK_FALLBACK,
|
| 2021 |
_INJECTED_PAYLOAD_ECHO_FALLBACK,
|
| 2022 |
-
_detect_injected_payload_echo,
|
| 2023 |
-
_detect_identity_leak,
|
| 2024 |
_scrub_identity_leak,
|
| 2025 |
_looks_like_injection_probe,
|
| 2026 |
)
|
|
@@ -3260,12 +3272,6 @@ def _build_openrouter_turn_messages(chat_id: int, user_text: str, model_id: str)
|
|
| 3260 |
# _run_streaming_reply) и экспоненциальное усреднение; при добавлении/замене
|
| 3261 |
# модели НИЧЕГО вручную обновлять не нужно — новая модель "нащупывает" свою
|
| 3262 |
# реальную скорость сама за первые несколько ответов.
|
| 3263 |
-
from lumen_typing_pace import (
|
| 3264 |
-
speed_key as _typing_speed_key,
|
| 3265 |
-
get_typing_speed as _get_typing_speed,
|
| 3266 |
-
record_observed_speed as _record_typing_speed,
|
| 3267 |
-
catchup_reveal_steps as _typing_catchup_steps,
|
| 3268 |
-
)
|
| 3269 |
|
| 3270 |
# Самокалибрующаяся оценка задержек моделей — см. докстринг lumen_model_speed.py:
|
| 3271 |
# переупорядочивание маршрута по измеренной скорости (_run_route) и адаптивный
|
|
@@ -3292,412 +3298,17 @@ _typing_sleep = asyncio.sleep
|
|
| 3292 |
_dots_sleep = asyncio.sleep
|
| 3293 |
|
| 3294 |
|
| 3295 |
-
|
| 3296 |
-
|
| 3297 |
-
|
| 3298 |
-
|
| 3299 |
-
|
| 3300 |
-
|
| 3301 |
-
|
| 3302 |
-
|
| 3303 |
-
|
| 3304 |
-
|
| 3305 |
-
|
| 3306 |
-
await _tg_call(placeholder.edit_text, _DOTS_FRAMES[frame_idx % len(_DOTS_FRAMES)], parse_mode=None, call_timeout=15.0)
|
| 3307 |
-
frame_idx += 1
|
| 3308 |
-
await _dots_sleep(_DOTS_TICK_SEC)
|
| 3309 |
-
except asyncio.CancelledError:
|
| 3310 |
-
raise
|
| 3311 |
-
|
| 3312 |
-
|
| 3313 |
-
async def _pieces_with_waiting_feedback(piece_agen, placeholder: Message, *, first_chunk_limit: float):
|
| 3314 |
-
"""Оборачивает генератор кусков стрима двумя вещами сразу (обе касаются
|
| 3315 |
-
только ОЖИДАНИЯ ПЕРВОГО куска — дальше генератор пробрасывается как есть):
|
| 3316 |
-
1. Адаптивный предел (см. lumen_model_speed.first_chunk_limit_sec): зависшая
|
| 3317 |
-
попытка бросается TimeoutError — вызывающий код (_run_streaming_reply)
|
| 3318 |
-
уже умеет отдавать плейсхолдер следующей модели по цепочке.
|
| 3319 |
-
2. Анимация точек (_tick_waiting_dots): гасится строго до yield первого
|
| 3320 |
-
куска, поэтому с показом текста не пересекается ни одним кадром.
|
| 3321 |
-
Пустой стрим (StopAsyncIteration сразу) — просто конец без кусков."""
|
| 3322 |
-
dots_task = asyncio.create_task(_tick_waiting_dots(placeholder))
|
| 3323 |
-
try:
|
| 3324 |
-
try:
|
| 3325 |
-
first = await asyncio.wait_for(piece_agen.__anext__(), timeout=first_chunk_limit)
|
| 3326 |
-
except StopAsyncIteration:
|
| 3327 |
-
return
|
| 3328 |
-
finally:
|
| 3329 |
-
dots_task.cancel()
|
| 3330 |
-
with contextlib.suppress(asyncio.CancelledError):
|
| 3331 |
-
await dots_task
|
| 3332 |
-
yield first
|
| 3333 |
-
async for piece in piece_agen:
|
| 3334 |
-
yield piece
|
| 3335 |
-
finally:
|
| 3336 |
-
aclose = getattr(piece_agen, "aclose", None)
|
| 3337 |
-
if aclose is not None:
|
| 3338 |
-
with contextlib.suppress(Exception):
|
| 3339 |
-
await aclose()
|
| 3340 |
-
|
| 3341 |
-
async def _gemini_stream_pieces(model_id: str, call_contents: list, gconfig):
|
| 3342 |
-
"""Асинхронный генератор кусков текста от Gemini — тонкая обёртка над
|
| 3343 |
-
client.aio.models.generate_content_stream с таймаутом на КАЖДЫЙ следующий
|
| 3344 |
-
кусок (см. STREAM_CHUNK_TIMEOUT_SEC), чтобы генуинно подвисший стрим не
|
| 3345 |
-
держал лок чата бесконечно. Провайдер-специфичная часть стриминга — вся
|
| 3346 |
-
Telegram-логика (плейсхолдер, чанкинг, троттлинг, защита от утечек) теперь
|
| 3347 |
-
общая для любого провайдера, см. _run_streaming_reply."""
|
| 3348 |
-
stream = await client.aio.models.generate_content_stream(model=model_id, contents=call_contents, config=gconfig)
|
| 3349 |
-
stream_iter = stream.__aiter__()
|
| 3350 |
-
try:
|
| 3351 |
-
while True:
|
| 3352 |
-
try:
|
| 3353 |
-
chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=STREAM_CHUNK_TIMEOUT_SEC)
|
| 3354 |
-
except StopAsyncIteration:
|
| 3355 |
-
break
|
| 3356 |
-
try:
|
| 3357 |
-
piece = getattr(chunk, "text", "") or ""
|
| 3358 |
-
except Exception:
|
| 3359 |
-
piece = ""
|
| 3360 |
-
if piece:
|
| 3361 |
-
yield piece
|
| 3362 |
-
finally:
|
| 3363 |
-
aclose = getattr(stream, "aclose", None)
|
| 3364 |
-
if aclose is not None:
|
| 3365 |
-
with contextlib.suppress(Exception):
|
| 3366 |
-
await aclose()
|
| 3367 |
-
|
| 3368 |
-
async def _openrouter_stream_pieces(model_id: str, messages: list[dict]):
|
| 3369 |
-
"""Асинхронный генератор кусков текста от OpenRouter — SSE-стриминг
|
| 3370 |
-
(`"stream": true`) через тот же chat/completions эндпоинт, что и обычный
|
| 3371 |
-
(нестримленный) вызов. OpenRouter отдаёт события построчно, вида
|
| 3372 |
-
`data: {...}\\n\\n`, с финальной строкой `data: [DONE]`. Таймаут на каждую
|
| 3373 |
-
следующую строку — тот же STREAM_CHUNK_TIMEOUT_SEC, что и у Gemini."""
|
| 3374 |
-
if not OPENROUTER_API_KEY:
|
| 3375 |
-
raise OpenRouterAPIError("OPENROUTER_API_KEY is not set")
|
| 3376 |
-
headers = {
|
| 3377 |
-
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 3378 |
-
"HTTP-Referer": OPENROUTER_HTTP_REFERER,
|
| 3379 |
-
"X-OpenRouter-Title": OPENROUTER_TITLE,
|
| 3380 |
-
"Content-Type": "application/json",
|
| 3381 |
-
}
|
| 3382 |
-
session = await _get_http_session()
|
| 3383 |
-
url = f"{OPENROUTER_BASE_URL}/chat/completions"
|
| 3384 |
-
payload = {"model": model_id, "messages": messages, "stream": True}
|
| 3385 |
-
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=None, connect=12.0)) as resp:
|
| 3386 |
-
if resp.status >= 400:
|
| 3387 |
-
body = await resp.read()
|
| 3388 |
-
raise OpenRouterAPIError(f"HTTP {resp.status}: {body[:300]!r}", status_code=resp.status)
|
| 3389 |
-
line_iter = resp.content.__aiter__()
|
| 3390 |
-
while True:
|
| 3391 |
-
try:
|
| 3392 |
-
raw_line = await asyncio.wait_for(line_iter.__anext__(), timeout=STREAM_CHUNK_TIMEOUT_SEC)
|
| 3393 |
-
except StopAsyncIteration:
|
| 3394 |
-
break
|
| 3395 |
-
line = raw_line.decode("utf-8", errors="ignore").strip()
|
| 3396 |
-
if not line or not line.startswith("data:"):
|
| 3397 |
-
continue
|
| 3398 |
-
data_str = line[len("data:"):].strip()
|
| 3399 |
-
if data_str == "[DONE]":
|
| 3400 |
-
break
|
| 3401 |
-
try:
|
| 3402 |
-
obj = json.loads(data_str)
|
| 3403 |
-
except Exception:
|
| 3404 |
-
continue
|
| 3405 |
-
# НАЙДЕНО ПРИ АУДИТЕ СТРИМИНГА: если провайдер за OpenRouter падает
|
| 3406 |
-
# УЖЕ ПОСЛЕ старта генерации (не сразу, на середине ответа), сам HTTP-
|
| 3407 |
-
# статус остаётся 200 (стрим уже открыт) — ошибка приходит не как
|
| 3408 |
-
# resp.status >= 400 выше, а прямо ВНУТРИ SSE-чанка:
|
| 3409 |
-
# {"error": {"message": ..., "code": ...}} вместо {"choices": [...]}.
|
| 3410 |
-
# Раньше такой чанк тихо пропускался (choices пустой -> continue), и
|
| 3411 |
-
# пользователь получал молча укороченный ответ без единого намёка на
|
| 3412 |
-
# причину — то же самое силентное поглощение, которого проект уже
|
| 3413 |
-
# избегает во всех остальных местах (ask_gemini/ask_openrouter_text).
|
| 3414 |
-
# Поднимаем как OpenRouterAPIError — дальше её уже штатно обрабатывает
|
| 3415 |
-
# _run_streaming_reply: ранний сбой (ничего ещё не показано) -> откат
|
| 3416 |
-
# на следующую модель маршрута, поздний сбой (часть ответа уже
|
| 3417 |
-
# показана) -> честная пометка "соединение прервалось".
|
| 3418 |
-
err_obj = obj.get("error")
|
| 3419 |
-
if err_obj:
|
| 3420 |
-
err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
|
| 3421 |
-
err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
|
| 3422 |
-
raise OpenRouterAPIError(err_msg or "OpenRouter returned an error in the stream body", status_code=err_code)
|
| 3423 |
-
choices = obj.get("choices") or []
|
| 3424 |
-
if not choices:
|
| 3425 |
-
continue
|
| 3426 |
-
delta = choices[0].get("delta") or {}
|
| 3427 |
-
piece = delta.get("content") or ""
|
| 3428 |
-
if piece:
|
| 3429 |
-
yield piece
|
| 3430 |
-
|
| 3431 |
-
async def _run_streaming_reply(
|
| 3432 |
-
chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
|
| 3433 |
-
) -> tuple[str | None, Message | None]:
|
| 3434 |
-
"""Провайдер-агностичная реализация стриминга: принимает асинхронный генератор
|
| 3435 |
-
кусков текста (см. _gemini_stream_pieces/_openrouter_stream_pieces) и делает
|
| 3436 |
-
всё остальное — плейсхолдер, разбиение на несколько сообщений при превышении
|
| 3437 |
-
лимита Telegram, троттлинг edit_text, обрыв при обнаружении утечки идентичности
|
| 3438 |
-
или эха внедрённой инъекции, финальную HTML-конвертацию и запись в общую
|
| 3439 |
-
историю чата. Раньше эта логика была написана только под Gemini — сейчас она
|
| 3440 |
-
ОДНА на любого провайдера, чтобы стриминг работал одинаково для Gemini и для
|
| 3441 |
-
OpenRouter (см. _try_gemini_streaming/_try_openrouter_streaming — тонкие
|
| 3442 |
-
обёртки, которые строят провайдер-специфичный call_contents/messages и
|
| 3443 |
-
генератор кусков, а дальше передают его сюда).
|
| 3444 |
-
|
| 3445 |
-
Возвращает (ответ, плейсхолдер):
|
| 3446 |
-
- Успех: (текст_ответа, None) — плейсхолдер уже отредактирован до финального
|
| 3447 |
-
текста, вызывающему коду больше нечего с ним делать.
|
| 3448 |
-
- Ошибка ДО показа хоть одного символа ответа: (None, плейсхолдер_или_None).
|
| 3449 |
-
РАНЬШЕ плейсхолдер тут же удалялся, и следующая модель по цепочке отправляла
|
| 3450 |
-
СОВСЕМ НОВОЕ сообщение — визуально это выглядело как "точки исчезли, потом
|
| 3451 |
-
из ниоткуда появился целый ответ одним блоком", без единого "живого" эффекта.
|
| 3452 |
-
Теперь плейсхолдер НЕ удаляется здесь — он возвращается вызывающему коду
|
| 3453 |
-
(_run_route), чтобы тот попробовал доправить в него финальный ответ от
|
| 3454 |
-
следующей модели по цепочке напрямую, а не создавать новое сообщение.
|
| 3455 |
-
Если ни одна дальнейшая модель не пригодится, _run_route сам аккуратно
|
| 3456 |
-
уберёт этот плейсхолдер.
|
| 3457 |
-
- Ошибка ПОСЛЕ показа части ответа — уже показанное не удаляется и не
|
| 3458 |
-
подменяется другим ответом, в конец добавляется пометка о возможном обрыве;
|
| 3459 |
-
возвращается (текст_ответа, None), как и при обычном успехе.
|
| 3460 |
-
"""
|
| 3461 |
-
state = get_state(chat_id)
|
| 3462 |
-
hist = state.setdefault("history", [])
|
| 3463 |
-
ctx = state.get("ctx", deque())
|
| 3464 |
-
|
| 3465 |
-
sent_messages: list[Message] = []
|
| 3466 |
-
full_text = ""
|
| 3467 |
-
last_edit_ts = 0.0
|
| 3468 |
-
last_edited_plain = ""
|
| 3469 |
-
first_piece_ts: float | None = None
|
| 3470 |
-
# pace_key/overall_start_ts/current_chunk_start_ts — см. lumen_typing_pace.py.
|
| 3471 |
-
# overall_start_ts фиксируется ОДИН раз (для замера реальной скорости бэкенда
|
| 3472 |
-
# целиком, даже если ответ займёт несколько сообщений), current_chunk_start_ts
|
| 3473 |
-
# сбрасывается на каждое НОВОЕ сообщение (см. continuation ниже) — паттерн
|
| 3474 |
-
# печати у каждого отд��льного Telegram-сообщения свой, начинается заново.
|
| 3475 |
-
pace_key = _typing_speed_key(provider, model_id)
|
| 3476 |
-
overall_start_ts = time.monotonic()
|
| 3477 |
-
current_chunk_start_ts = overall_start_ts
|
| 3478 |
-
|
| 3479 |
-
try:
|
| 3480 |
-
placeholder = await _tg_call(message.reply, "…", call_timeout=TELEGRAM_REQUEST_TIMEOUT)
|
| 3481 |
-
if placeholder is None:
|
| 3482 |
-
return None, None
|
| 3483 |
-
sent_messages.append(placeholder)
|
| 3484 |
-
|
| 3485 |
-
# Обёртка ожидания первого куска: адаптивный предел + бегущие точки
|
| 3486 |
-
# (см. _pieces_with_waiting_feedback). Дальше цикл не отличим от чтения
|
| 3487 |
-
# исходного генератора напрямую — все существующие проверки кусков,
|
| 3488 |
-
# утечек и троттлинга ниже работают без изменений.
|
| 3489 |
-
piece_agen = _pieces_with_waiting_feedback(
|
| 3490 |
-
piece_agen, placeholder,
|
| 3491 |
-
first_chunk_limit=_model_first_chunk_limit(_model_speed_key(provider, model_id), FIRST_CHUNK_TIMEOUT_SEC),
|
| 3492 |
-
)
|
| 3493 |
-
async for piece in piece_agen:
|
| 3494 |
-
if not piece:
|
| 3495 |
-
continue
|
| 3496 |
-
if first_piece_ts is None:
|
| 3497 |
-
first_piece_ts = time.monotonic()
|
| 3498 |
-
full_text += piece
|
| 3499 |
-
|
| 3500 |
-
leak_kind = None
|
| 3501 |
-
_scan_text = _leak_scan_window(full_text, piece)
|
| 3502 |
-
if _detect_identity_leak(_scan_text):
|
| 3503 |
-
leak_kind = "identity"
|
| 3504 |
-
elif _detect_injected_payload_echo(_scan_text):
|
| 3505 |
-
leak_kind = "payload_echo"
|
| 3506 |
-
|
| 3507 |
-
if leak_kind:
|
| 3508 |
-
# Проверяем СРАЗУ после накопления куска и ДО любого edit_text ниже —
|
| 3509 |
-
# на этот момент ни одно уже показанное пользователю сообщение ещё не
|
| 3510 |
-
# содержит только что добавленный (утекающий) кусок текста, поэтому
|
| 3511 |
-
# обрыв здесь гарантированно не даёт утечке "мигнуть" на экране хотя бы
|
| 3512 |
-
# на долю секунды, в отличие от проверки уже после финальной правки.
|
| 3513 |
-
tag = "identity-leak" if leak_kind == "identity" else "injection-echo"
|
| 3514 |
-
log.warning(
|
| 3515 |
-
'[%s] Stream %s/%s started leaking internal details/echoing an injected instruction — aborting the stream and showing a neutral reply instead of the partially accumulated text: %r', tag, provider, model_id, full_text[:500],
|
| 3516 |
-
)
|
| 3517 |
-
aclose = getattr(piece_agen, "aclose", None)
|
| 3518 |
-
if aclose is not None:
|
| 3519 |
-
with contextlib.suppress(Exception):
|
| 3520 |
-
await aclose()
|
| 3521 |
-
final_answer = _IDENTITY_LEAK_FALLBACK if leak_kind == "identity" else _INJECTED_PAYLOAD_ECHO_FALLBACK
|
| 3522 |
-
await _tg_call(sent_messages[-1].edit_text, final_answer, parse_mode=None, call_timeout=15.0)
|
| 3523 |
-
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 3524 |
-
hist.append({"role": "assistant", "content": final_answer})
|
| 3525 |
-
if len(hist) > SHARED_HISTORY_MAX_LEN:
|
| 3526 |
-
del hist[:-SHARED_HISTORY_MAX_LEN]
|
| 3527 |
-
ctx.clear()
|
| 3528 |
-
_record_quota_usage(provider, model_id)
|
| 3529 |
-
return final_answer, None
|
| 3530 |
-
|
| 3531 |
-
chunks = _split_text_chunks(full_text, TG_MAX_LEN)
|
| 3532 |
-
# Финализируем все чанки кроме последнего (текущего, ещё растущего) —
|
| 3533 |
-
# тот же алгоритм разбиения, что и для обычных (нестримленных) длинных ответов.
|
| 3534 |
-
while len(chunks) > len(sent_messages):
|
| 3535 |
-
idx = len(sent_messages) - 1
|
| 3536 |
-
# НАЙДЕНО ПРИ АУДИТЕ СТРИМИНГА: раньше здесь СНАЧАЛА финализировался
|
| 3537 |
-
# sent_messages[idx] полным edit_text, а ПОТОМ, если открыть сообщение-
|
| 3538 |
-
# продолжение не удавалось, тот же самый edit_text вызывался ЕЩЁ РАЗ с
|
| 3539 |
-
# тем же текстом плюс пометка — два сетевых вызова ради одного и того же
|
| 3540 |
-
# результата в ветке отказа. Пробуем continuation ПЕРВЫМ, и финализируем
|
| 3541 |
-
# idx ровно одним вызовом сразу с нужным текстом (с пометкой или без) —
|
| 3542 |
-
# тот же итоговый результат для пользователя, но без лишнего HTTP-запроса
|
| 3543 |
-
# к Telegram, когда continuation всё равно проваливается.
|
| 3544 |
-
new_msg = await _tg_call(bot.send_message, chat_id=message.chat.id, text="…", call_timeout=TELEGRAM_REQUEST_TIMEOUT)
|
| 3545 |
-
if new_msg is None:
|
| 3546 |
-
# Не удалось открыть сообщение под продолжение. ВАЖНО: sent_messages[idx]
|
| 3547 |
-
# ещё НЕ финализирован — это НЕ то же самое, что общий except ниже
|
| 3548 |
-
# (который считает, что sent_messages[-1] соответствует chunks[-1] —
|
| 3549 |
-
# здесь это неверно, там уже другой, ещё не начатый кусок). Обрабатываем
|
| 3550 |
-
# прямо тут, не давая общему except перезаписать корректно показанный
|
| 3551 |
-
# текст чужим содержимым.
|
| 3552 |
-
note = _t(message.chat.id, "stream_note_send_fail")
|
| 3553 |
-
with contextlib.suppress(Exception):
|
| 3554 |
-
await _tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]) + note, parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 3555 |
-
final_answer = full_text.strip()
|
| 3556 |
-
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 3557 |
-
hist.append({"role": "assistant", "content": final_answer})
|
| 3558 |
-
if len(hist) > SHARED_HISTORY_MAX_LEN:
|
| 3559 |
-
del hist[:-SHARED_HISTORY_MAX_LEN]
|
| 3560 |
-
ctx.clear()
|
| 3561 |
-
_record_quota_usage(provider, model_id)
|
| 3562 |
-
return final_answer, None
|
| 3563 |
-
await _tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]), parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 3564 |
-
sent_messages.append(new_msg)
|
| 3565 |
-
last_edited_plain = ""
|
| 3566 |
-
# Новое сообщение — новый "лист", печать в нём начинается с нуля.
|
| 3567 |
-
current_chunk_start_ts = time.monotonic()
|
| 3568 |
-
|
| 3569 |
-
# Троттлинг: реальный edit_text не чаще ~раза в STREAM_EDIT_MIN_INTERVAL_SEC,
|
| 3570 |
-
# иначе Telegram начинает отвечать 429 на слишком частые правки одного
|
| 3571 |
-
# сообщения. Видимый текст — не всё, что уже накоплено (full_text), а
|
| 3572 |
-
# срез, растущий по оценённой скорости печати этой модели (см.
|
| 3573 |
-
# lumen_typing_pace.py) — реальному приходу кусков он "верит" только
|
| 3574 |
-
# как верхней границе (min(...)): если модель прислала текст МЕДЛЕННЕЕ
|
| 3575 |
-
# оценённой скорости, показывается всё, что реально пришло, без
|
| 3576 |
-
# искусственного придерживания; лимитирует именно случай, когда бэкенд
|
| 3577 |
-
# присылает крупными редкими кусками быстрее, чем "читалось" бы вслух.
|
| 3578 |
-
now = time.monotonic()
|
| 3579 |
-
target_full = chunks[-1] if chunks else ""
|
| 3580 |
-
typing_speed = _get_typing_speed(pace_key)
|
| 3581 |
-
reveal_len = min(len(target_full), max(0, int((now - current_chunk_start_ts) * typing_speed)))
|
| 3582 |
-
current_chunk_text = target_full[:reveal_len]
|
| 3583 |
-
if now - last_edit_ts >= STREAM_EDIT_MIN_INTERVAL_SEC and current_chunk_text != last_edited_plain:
|
| 3584 |
-
await _tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
|
| 3585 |
-
last_edited_plain = current_chunk_text
|
| 3586 |
-
last_edit_ts = now
|
| 3587 |
-
|
| 3588 |
-
if not full_text.strip():
|
| 3589 |
-
# Стрим завершился, но не прислал ни одного символа текста — считаем
|
| 3590 |
-
# попытку неудавшейся. Плейсхолдер НЕ удаляем (см. докстринг) — отдаём
|
| 3591 |
-
# его вызывающему коду, вдруг пригодится для следующей модели.
|
| 3592 |
-
return None, sent_messages[-1]
|
| 3593 |
-
|
| 3594 |
-
# Замер РЕАЛЬНОЙ скорости бэкенда — обязательно ДО фазы "довывода" ниже,
|
| 3595 |
-
# иначе самим же добавленная пауза исказила бы будущую оценку скорости
|
| 3596 |
-
# этой модели (см. докстринг record_observed_speed в lumen_typing_pace.py).
|
| 3597 |
-
_record_typing_speed(pace_key, time.monotonic() - overall_start_ts, len(full_text))
|
| 3598 |
-
# Замер задержки модели для умного роутера (см. lumen_model_speed.py) —
|
| 3599 |
-
# рядом с замером скорости печати, из тех же меток времени. first_piece_ts
|
| 3600 |
-
# здесь уже точно установлен (успех означает ≥1 кусок). Catch-up паузы
|
| 3601 |
-
# сознательно ВКЛЮЧЕНЫ в total: для решения "кого ставить первым" важна
|
| 3602 |
-
# задержка, видимая пользователем, а не только время бэкенда.
|
| 3603 |
-
_record_model_latency(
|
| 3604 |
-
_model_speed_key(provider, model_id),
|
| 3605 |
-
total_sec=time.monotonic() - overall_start_ts,
|
| 3606 |
-
ttf_sec=(first_piece_ts - overall_start_ts) if first_piece_ts is not None else None,
|
| 3607 |
-
)
|
| 3608 |
-
|
| 3609 |
-
# "Довывод" остатка последнего сообщения, который стрим уже прислал
|
| 3610 |
-
# целиком, но пейсинг выше ещё не успел показать (частый случай для
|
| 3611 |
-
# бэкендов, присылающих готовый текст одним большим SSE-куском в конце —
|
| 3612 |
-
# см. lumen_typing_pace.py) — без этого пользователь увидел бы "…" почти
|
| 3613 |
-
# до самого конца, а затем весь ответ разом. Ограничено по построению
|
| 3614 |
-
# (см. catchup_reveal_steps) сверху STREAM_TYPING_MAX_CATCHUP_TICKS *
|
| 3615 |
-
# STREAM_TYPING_TICK_SEC секунд — не тянет отправку ответа надолго.
|
| 3616 |
-
final_chunks = _split_text_chunks(full_text, TG_MAX_LEN)
|
| 3617 |
-
target_full = final_chunks[-1]
|
| 3618 |
-
already_shown_len = len(last_edited_plain) if last_edited_plain and target_full.startswith(last_edited_plain) else 0
|
| 3619 |
-
remaining_len = len(target_full) - already_shown_len
|
| 3620 |
-
if remaining_len > 0:
|
| 3621 |
-
typing_speed = _get_typing_speed(pace_key)
|
| 3622 |
-
for step_len in _typing_catchup_steps(remaining_len, typing_speed, STREAM_TYPING_TICK_SEC, STREAM_TYPING_MAX_CATCHUP_TICKS):
|
| 3623 |
-
await _typing_sleep(STREAM_TYPING_TICK_SEC)
|
| 3624 |
-
current_chunk_text = target_full[:already_shown_len + step_len]
|
| 3625 |
-
if current_chunk_text != last_edited_plain:
|
| 3626 |
-
await _tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
|
| 3627 |
-
last_edited_plain = current_chunk_text
|
| 3628 |
-
|
| 3629 |
-
# Финальный сброс последнего сообщения — уже с полной HTML-конвертацией
|
| 3630 |
-
# markdown (во время стрима сознательно показывался голый текст: частичный
|
| 3631 |
-
# markdown при редактировании мог бы дать несбалансированные теги и сломать
|
| 3632 |
-
# parse_mode=HTML на промежуточных правках).
|
| 3633 |
-
final_text = final_chunks[-1]
|
| 3634 |
-
# _edit_message_quietly уже инкапсулирует тот же HTML->plain fallback,
|
| 3635 |
-
# что здесь раньше был продублирован вручную (см. аудит техдолга) —
|
| 3636 |
-
# **kwargs проходит через неё прямиком в _tg_call, поэтому call_timeout
|
| 3637 |
-
# передаётся без изменений в сигнатуре самой _edit_message_quietly.
|
| 3638 |
-
await _edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
| 3639 |
-
|
| 3640 |
-
except Exception as exc:
|
| 3641 |
-
if not full_text.strip():
|
| 3642 |
-
# ВАЖНО: плейсхолдер больше НЕ удаляется здесь (в отличие от старой
|
| 3643 |
-
# версии) — см. докстринг функции про переиспользование сообщения.
|
| 3644 |
-
log.warning('[stream] Stream %s/%s failed before showing any content, falling back to a regular call: %s', provider, model_id, exc)
|
| 3645 |
-
return None, (sent_messages[-1] if sent_messages else None)
|
| 3646 |
-
log.warning('[stream] Stream %s/%s failed after partially showing the response, finishing as-is: %s', provider, model_id, exc)
|
| 3647 |
-
if _detect_identity_leak(full_text):
|
| 3648 |
-
# На практике сюда почти невозможно попасть (см. проверку сразу после
|
| 3649 |
-
# каждого куска выше) — оставлено как последняя страховка на случай бага
|
| 3650 |
-
# в основной проверке, а не полагаясь только на один рубеж.
|
| 3651 |
-
log.warning('[identity-leak] Leak caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
|
| 3652 |
-
full_text = _IDENTITY_LEAK_FALLBACK
|
| 3653 |
-
with contextlib.suppress(Exception):
|
| 3654 |
-
await _tg_call(sent_messages[-1].edit_text, _IDENTITY_LEAK_FALLBACK, parse_mode=None, call_timeout=15.0)
|
| 3655 |
-
elif _detect_injected_payload_echo(full_text):
|
| 3656 |
-
log.warning('[injection-echo] Injected-instruction echo caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
|
| 3657 |
-
full_text = _INJECTED_PAYLOAD_ECHO_FALLBACK
|
| 3658 |
-
with contextlib.suppress(Exception):
|
| 3659 |
-
await _tg_call(sent_messages[-1].edit_text, _INJECTED_PAYLOAD_ECHO_FALLBACK, parse_mode=None, call_timeout=15.0)
|
| 3660 |
-
else:
|
| 3661 |
-
with contextlib.suppress(Exception):
|
| 3662 |
-
chunks = _split_text_chunks(full_text, TG_MAX_LEN)
|
| 3663 |
-
final_text = chunks[-1] if chunks else full_text
|
| 3664 |
-
note = _t(message.chat.id, "stream_note_interrupted")
|
| 3665 |
-
await _tg_call(sent_messages[-1].edit_text, _md_to_html(final_text + note), parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 3666 |
-
finally:
|
| 3667 |
-
aclose = getattr(piece_agen, "aclose", None)
|
| 3668 |
-
if aclose is not None:
|
| 3669 |
-
with contextlib.suppress(Exception):
|
| 3670 |
-
await aclose()
|
| 3671 |
-
|
| 3672 |
-
# Пустой full_text сюда не доходит (проверка выше возвращает None раньше),
|
| 3673 |
-
# поэтому фолбэка "Empty response" больше нет — мёртвый код убран.
|
| 3674 |
-
final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
| 3675 |
-
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 3676 |
-
hist.append({"role": "assistant", "content": final_answer})
|
| 3677 |
-
if len(hist) > SHARED_HISTORY_MAX_LEN:
|
| 3678 |
-
del hist[:-SHARED_HISTORY_MAX_LEN]
|
| 3679 |
-
ctx.clear()
|
| 3680 |
-
_record_quota_usage(provider, model_id)
|
| 3681 |
-
return final_answer, None
|
| 3682 |
-
|
| 3683 |
-
async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message, model_id: str) -> tuple[str | None, Message | None]:
|
| 3684 |
-
"""Тонкая обёртка над _run_streaming_reply для Gemini: строит contents/config,
|
| 3685 |
-
специфичные для Gemini API, и передаёт их в общую (провайдер-агностичную)
|
| 3686 |
-
реализацию стриминга. Возвращает (ответ, плейсхолдер) — см. _run_streaming_reply."""
|
| 3687 |
-
conf = GEMINI_MODELS.get(model_id, {})
|
| 3688 |
-
if not conf.get("stream", True):
|
| 3689 |
-
return None, None
|
| 3690 |
-
contents = await _build_gemini_turn_contents(chat_id, user_text)
|
| 3691 |
-
call_contents, gconfig = _build_gemini_call_config(model_id, contents)
|
| 3692 |
-
piece_agen = _gemini_stream_pieces(model_id, call_contents, gconfig)
|
| 3693 |
-
return await _run_streaming_reply(chat_id, user_text, message, provider="gemini", model_id=model_id, piece_agen=piece_agen)
|
| 3694 |
-
|
| 3695 |
-
async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Message, model_id: str) -> tuple[str | None, Message | None]:
|
| 3696 |
-
"""Тонкая обёртка над _run_streaming_reply для OpenRouter — тот же принцип,
|
| 3697 |
-
что и _try_gemini_streaming, но с SSE-стримингом через chat/completions."""
|
| 3698 |
-
messages = _build_openrouter_turn_messages(chat_id, user_text, model_id)
|
| 3699 |
-
piece_agen = _openrouter_stream_pieces(model_id, messages)
|
| 3700 |
-
return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen)
|
| 3701 |
|
| 3702 |
# определение ссылок и упоминаний, триггеры draw/tts, категории медиа —
|
| 3703 |
# вынесено в lumen_message_parse.py (срез монолита): чистые функции над
|
|
|
|
| 1292 |
"webhook_handler",
|
| 1293 |
"network_diagnostics",
|
| 1294 |
"export_state",
|
| 1295 |
+
# Имена из lumen_streaming.py код bot.py сам не читает — они нужны как `bot.X`
|
| 1296 |
+
# существующим тестам и `_run_route` ниже.
|
| 1297 |
+
"_tick_waiting_dots",
|
| 1298 |
+
"_pieces_with_waiting_feedback",
|
| 1299 |
+
"_gemini_stream_pieces",
|
| 1300 |
+
"_openrouter_stream_pieces",
|
| 1301 |
+
"_run_streaming_reply",
|
| 1302 |
+
"_try_gemini_streaming",
|
| 1303 |
+
"_try_openrouter_streaming",
|
| 1304 |
+
# Точка подмены тестов (см. monkeypatch в test_bot.py) — сам код bot.py её
|
| 1305 |
+
# больше не читает напрямую после выноса стриминга.
|
| 1306 |
+
"_model_first_chunk_limit",
|
| 1307 |
+
# Константы-фолбэки тестов (читаются как bot._IDENTITY_LEAK_FALLBACK).
|
| 1308 |
+
"_IDENTITY_LEAK_FALLBACK",
|
| 1309 |
+
"_INJECTED_PAYLOAD_ECHO_FALLBACK",
|
| 1310 |
]
|
| 1311 |
|
| 1312 |
def _upstash_request(command_path: str, *, method: str = "GET", body: bytes | None = None) -> Any:
|
|
|
|
| 2031 |
# составляющие их `_LEAK_BRAND_TOKENS`/`_LEAK_LITERAL_STRINGS`) нужны только
|
| 2032 |
# самим детекторам внутри lumen_security.py, а не коду bot.py.
|
| 2033 |
from lumen_security import (
|
|
|
|
| 2034 |
_IDENTITY_LEAK_FALLBACK,
|
| 2035 |
_INJECTED_PAYLOAD_ECHO_FALLBACK,
|
|
|
|
|
|
|
| 2036 |
_scrub_identity_leak,
|
| 2037 |
_looks_like_injection_probe,
|
| 2038 |
)
|
|
|
|
| 3272 |
# _run_streaming_reply) и экспоненциальное усреднение; при добавлении/замене
|
| 3273 |
# модели НИЧЕГО вручную обновлять не нужно — новая модель "нащупывает" свою
|
| 3274 |
# реальную скорость сама за первые несколько ответов.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3275 |
|
| 3276 |
# Самокалибрующаяся оценка задержек моделей — см. докстринг lumen_model_speed.py:
|
| 3277 |
# переупорядочивание маршрута по измеренной скорости (_run_route) и адаптивный
|
|
|
|
| 3298 |
_dots_sleep = asyncio.sleep
|
| 3299 |
|
| 3300 |
|
| 3301 |
+
# Стриминг живёт в lumen_streaming.py (P2): здесь только реэкспорт имён,
|
| 3302 |
+
# чтобы `bot.X` в тестах и `_run_route` не менялись.
|
| 3303 |
+
from lumen_streaming import (
|
| 3304 |
+
_tick_waiting_dots,
|
| 3305 |
+
_pieces_with_waiting_feedback,
|
| 3306 |
+
_gemini_stream_pieces,
|
| 3307 |
+
_openrouter_stream_pieces,
|
| 3308 |
+
_run_streaming_reply,
|
| 3309 |
+
_try_gemini_streaming,
|
| 3310 |
+
_try_openrouter_streaming,
|
| 3311 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3312 |
|
| 3313 |
# определение ссылок и упоминаний, триггеры draw/tts, категории медиа —
|
| 3314 |
# вынесено в lumen_message_parse.py (срез монолита): чистые функции над
|
lumen_streaming.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
lumen_streaming.py — провайдер-агностичный стриминг ответов (вынесено из bot.py,
|
| 3 |
+
P2 аудита): плейсхолдер, бегущие точки, троттлинг правок, пейсинг по измеренной
|
| 4 |
+
скорости, скраб утечек на каждый чанк, catch-up довывод.
|
| 5 |
+
|
| 6 |
+
Связи с рантаймом bot.py — ТОЛЬКО через отложенный `import bot` внутри функций
|
| 7 |
+
(модульного цикла нет). bot.py реэкспортирует имена — `bot._run_streaming_reply`
|
| 8 |
+
и т.п. в тестах и `_run_route` не менялись. Точки подмены для тестов
|
| 9 |
+
(`bot._typing_sleep`, `bot._dots_sleep`, `bot._DOTS_*`) читаются через `bot.`
|
| 10 |
+
в момент вызова, поэтому monkeypatch/conftest видят их как раньше.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import contextlib
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import time
|
| 19 |
+
from collections import deque
|
| 20 |
+
import aiohttp
|
| 21 |
+
from aiogram.enums import ParseMode
|
| 22 |
+
from aiogram.types import Message
|
| 23 |
+
|
| 24 |
+
from lumen_formatting import _md_to_html, _split_text_chunks
|
| 25 |
+
from lumen_message_parse import _history_user_text
|
| 26 |
+
from lumen_model_speed import (
|
| 27 |
+
speed_key as _model_speed_key,
|
| 28 |
+
record_response as _record_model_latency,
|
| 29 |
+
first_chunk_limit_sec as _model_first_chunk_limit,
|
| 30 |
+
)
|
| 31 |
+
from lumen_router_config import GEMINI_MODELS
|
| 32 |
+
from lumen_security import (
|
| 33 |
+
_leak_scan_window,
|
| 34 |
+
_IDENTITY_LEAK_FALLBACK,
|
| 35 |
+
_INJECTED_PAYLOAD_ECHO_FALLBACK,
|
| 36 |
+
_detect_injected_payload_echo,
|
| 37 |
+
_detect_identity_leak,
|
| 38 |
+
_scrub_identity_leak,
|
| 39 |
+
)
|
| 40 |
+
from lumen_typing_pace import (
|
| 41 |
+
speed_key as _typing_speed_key,
|
| 42 |
+
get_typing_speed as _get_typing_speed,
|
| 43 |
+
record_observed_speed as _record_typing_speed,
|
| 44 |
+
catchup_reveal_steps as _typing_catchup_steps,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
log = logging.getLogger("bot")
|
| 48 |
+
|
| 49 |
+
async def _tick_waiting_dots(placeholder: Message) -> None:
|
| 50 |
+
"""Бегущие точки в плейсхолдере, пока стрим не прислал первый кусок.
|
| 51 |
+
Первый кадр — только после _DOTS_START_AFTER_SEC тишины (быстрые модели
|
| 52 |
+
анимации не видят вообще), дальше — кадр каждые _DOTS_TICK_SEC. Правки идут
|
| 53 |
+
через _tg_call (ошибки глотаются — плейсхолдер могли удалить/переиспользовать
|
| 54 |
+
параллельно), отмена задачи — штатный путь остановки после первого куска."""
|
| 55 |
+
import bot
|
| 56 |
+
try:
|
| 57 |
+
await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
|
| 58 |
+
frame_idx = 0
|
| 59 |
+
while True:
|
| 60 |
+
with contextlib.suppress(Exception):
|
| 61 |
+
await bot._tg_call(placeholder.edit_text, bot._DOTS_FRAMES[frame_idx % len(bot._DOTS_FRAMES)], parse_mode=None, call_timeout=15.0)
|
| 62 |
+
frame_idx += 1
|
| 63 |
+
await bot._dots_sleep(bot._DOTS_TICK_SEC)
|
| 64 |
+
except asyncio.CancelledError:
|
| 65 |
+
raise
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def _pieces_with_waiting_feedback(piece_agen, placeholder: Message, *, first_chunk_limit: float):
|
| 69 |
+
"""Оборачивает генератор кусков стрима двумя вещами сразу (обе касаются
|
| 70 |
+
только ОЖИДАНИЯ ПЕРВОГО куска — дальше генератор пробрасывается как есть):
|
| 71 |
+
1. Адаптивный предел (см. lumen_model_speed.first_chunk_limit_sec): зависшая
|
| 72 |
+
попытка бросается TimeoutError — вызывающий код (_run_streaming_reply)
|
| 73 |
+
уже умеет отдавать плейсхолдер следующей модели по цепочке.
|
| 74 |
+
2. Анимация точек (_tick_waiting_dots): гасится строго до yield первого
|
| 75 |
+
куска, поэтому с показом текста не пересекается ни одним кадром.
|
| 76 |
+
Пустой стрим (StopAsyncIteration сразу) — просто конец без кусков."""
|
| 77 |
+
dots_task = asyncio.create_task(_tick_waiting_dots(placeholder))
|
| 78 |
+
try:
|
| 79 |
+
try:
|
| 80 |
+
first = await asyncio.wait_for(piece_agen.__anext__(), timeout=first_chunk_limit)
|
| 81 |
+
except StopAsyncIteration:
|
| 82 |
+
return
|
| 83 |
+
finally:
|
| 84 |
+
dots_task.cancel()
|
| 85 |
+
with contextlib.suppress(asyncio.CancelledError):
|
| 86 |
+
await dots_task
|
| 87 |
+
yield first
|
| 88 |
+
async for piece in piece_agen:
|
| 89 |
+
yield piece
|
| 90 |
+
finally:
|
| 91 |
+
aclose = getattr(piece_agen, "aclose", None)
|
| 92 |
+
if aclose is not None:
|
| 93 |
+
with contextlib.suppress(Exception):
|
| 94 |
+
await aclose()
|
| 95 |
+
|
| 96 |
+
async def _gemini_stream_pieces(model_id: str, call_contents: list, gconfig):
|
| 97 |
+
"""Асинхронный генератор кусков текста от Gemini — тонкая обё��тка над
|
| 98 |
+
client.aio.models.generate_content_stream с таймаутом на КАЖДЫЙ следующий
|
| 99 |
+
кусок (см. STREAM_CHUNK_TIMEOUT_SEC), чтобы генуинно подвисший стрим не
|
| 100 |
+
держал лок чата бесконечно. Провайдер-специфичная часть стриминга — вся
|
| 101 |
+
Telegram-логика (плейсхолдер, чанкинг, троттлинг, защита от утечек) теперь
|
| 102 |
+
общая для любого провайдера, см. _run_streaming_reply."""
|
| 103 |
+
import bot
|
| 104 |
+
stream = await bot.client.aio.models.generate_content_stream(model=model_id, contents=call_contents, config=gconfig)
|
| 105 |
+
stream_iter = stream.__aiter__()
|
| 106 |
+
try:
|
| 107 |
+
while True:
|
| 108 |
+
try:
|
| 109 |
+
chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=bot.STREAM_CHUNK_TIMEOUT_SEC)
|
| 110 |
+
except StopAsyncIteration:
|
| 111 |
+
break
|
| 112 |
+
try:
|
| 113 |
+
piece = getattr(chunk, "text", "") or ""
|
| 114 |
+
except Exception:
|
| 115 |
+
piece = ""
|
| 116 |
+
if piece:
|
| 117 |
+
yield piece
|
| 118 |
+
finally:
|
| 119 |
+
aclose = getattr(stream, "aclose", None)
|
| 120 |
+
if aclose is not None:
|
| 121 |
+
with contextlib.suppress(Exception):
|
| 122 |
+
await aclose()
|
| 123 |
+
|
| 124 |
+
async def _openrouter_stream_pieces(model_id: str, messages: list[dict]):
|
| 125 |
+
"""Асинхронный генератор кусков текста от OpenRouter — SSE-стриминг
|
| 126 |
+
(`"stream": true`) через тот же chat/completions эндпоинт, что и обычный
|
| 127 |
+
(нестримленный) вызов. OpenRouter отдаёт события построчно, вида
|
| 128 |
+
`data: {...}\\n\\n`, с финальной строкой `data: [DONE]`. Таймаут на каждую
|
| 129 |
+
следующую строку — тот же STREAM_CHUNK_TIMEOUT_SEC, что и у Gemini."""
|
| 130 |
+
import bot
|
| 131 |
+
if not bot.OPENROUTER_API_KEY:
|
| 132 |
+
raise bot.OpenRouterAPIError("OPENROUTER_API_KEY is not set")
|
| 133 |
+
headers = {
|
| 134 |
+
"Authorization": f"Bearer {bot.OPENROUTER_API_KEY}",
|
| 135 |
+
"HTTP-Referer": bot.OPENROUTER_HTTP_REFERER,
|
| 136 |
+
"X-OpenRouter-Title": bot.OPENROUTER_TITLE,
|
| 137 |
+
"Content-Type": "application/json",
|
| 138 |
+
}
|
| 139 |
+
session = await bot._get_http_session()
|
| 140 |
+
url = f"{bot.OPENROUTER_BASE_URL}/chat/completions"
|
| 141 |
+
payload = {"model": model_id, "messages": messages, "stream": True}
|
| 142 |
+
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=None, connect=12.0)) as resp:
|
| 143 |
+
if resp.status >= 400:
|
| 144 |
+
body = await resp.read()
|
| 145 |
+
raise bot.OpenRouterAPIError(f"HTTP {resp.status}: {body[:300]!r}", status_code=resp.status)
|
| 146 |
+
line_iter = resp.content.__aiter__()
|
| 147 |
+
while True:
|
| 148 |
+
try:
|
| 149 |
+
raw_line = await asyncio.wait_for(line_iter.__anext__(), timeout=bot.STREAM_CHUNK_TIMEOUT_SEC)
|
| 150 |
+
except StopAsyncIteration:
|
| 151 |
+
break
|
| 152 |
+
line = raw_line.decode("utf-8", errors="ignore").strip()
|
| 153 |
+
if not line or not line.startswith("data:"):
|
| 154 |
+
continue
|
| 155 |
+
data_str = line[len("data:"):].strip()
|
| 156 |
+
if data_str == "[DONE]":
|
| 157 |
+
break
|
| 158 |
+
try:
|
| 159 |
+
obj = json.loads(data_str)
|
| 160 |
+
except Exception:
|
| 161 |
+
continue
|
| 162 |
+
# НАЙДЕНО ПРИ АУДИТЕ СТРИМИНГА: если провайдер за OpenRouter падает
|
| 163 |
+
# УЖЕ ПОСЛЕ старта генерации (не сразу, на середине ответа), сам HTTP-
|
| 164 |
+
# статус остаётся 200 (стрим уже открыт) — ошибка приходит не как
|
| 165 |
+
# resp.status >= 400 выше, а прямо ВНУТРИ SSE-чанка:
|
| 166 |
+
# {"error": {"message": ..., "code": ...}} вместо {"choices": [...]}.
|
| 167 |
+
# Раньше такой чанк тихо пропускался (choices пустой -> continue), и
|
| 168 |
+
# пользователь получал молча укороченный ответ без единого намёка на
|
| 169 |
+
# причину — то же самое силентное поглощение, которого проект уже
|
| 170 |
+
# избегает во всех остальных местах (ask_gemini/ask_openrouter_text).
|
| 171 |
+
# Поднимаем как OpenRouterAPIError — дальше её уже штатно обрабатывает
|
| 172 |
+
# _run_streaming_reply: ранний сбой (ничего ещё не показано) -> откат
|
| 173 |
+
# на следующую модель маршрута, поздний сбой (часть ответа уже
|
| 174 |
+
# показана) -> честная пометка "соединение прервалось".
|
| 175 |
+
err_obj = obj.get("error")
|
| 176 |
+
if err_obj:
|
| 177 |
+
err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
|
| 178 |
+
err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
|
| 179 |
+
raise bot.OpenRouterAPIError(err_msg or "OpenRouter returned an error in the stream body", status_code=err_code)
|
| 180 |
+
choices = obj.get("choices") or []
|
| 181 |
+
if not choices:
|
| 182 |
+
continue
|
| 183 |
+
delta = choices[0].get("delta") or {}
|
| 184 |
+
piece = delta.get("content") or ""
|
| 185 |
+
if piece:
|
| 186 |
+
yield piece
|
| 187 |
+
|
| 188 |
+
async def _run_streaming_reply(
|
| 189 |
+
chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
|
| 190 |
+
) -> tuple[str | None, Message | None]:
|
| 191 |
+
"""Провайдер-агностичная реализация стриминга: принимает асинхронный генератор
|
| 192 |
+
кусков текста (см. _gemini_stream_pieces/_openrouter_stream_pieces) и делает
|
| 193 |
+
всё остальное — плейсхолдер, разбиение на несколько сообщений при превышении
|
| 194 |
+
лимита Telegram, троттлинг edit_text, обрыв при обнаружении утечки идентичности
|
| 195 |
+
или эха внедрённой инъекции, финальную HTML-конвертацию и запись в общую
|
| 196 |
+
историю чата. Раньше эта логика была написана только под Gemini — сейчас она
|
| 197 |
+
ОДНА на любого провайдера, чтобы стриминг работал одинаково для Gemini и для
|
| 198 |
+
OpenRouter (см. _try_gemini_streaming/_try_openrouter_streaming — тонкие
|
| 199 |
+
обёртки, которые строят провайдер-специфичный call_contents/messages и
|
| 200 |
+
генератор кусков, а дальше передают его сюда).
|
| 201 |
+
|
| 202 |
+
Возвращает (ответ, плейсхолдер):
|
| 203 |
+
- Успех: (текст_ответа, None) — плейсхолдер уже отредактирован до финального
|
| 204 |
+
текста, вызывающему коду больше нечего с ним делать.
|
| 205 |
+
- Ошибка ДО показа хоть одного символа ответа: (None, плейсхолдер_или_None).
|
| 206 |
+
РАНЬШЕ плейсхолдер тут же удалялся, и следующая модель по цепочке отправляла
|
| 207 |
+
СОВСЕМ НОВОЕ сообщение — визуально это выглядело как "точки исчезли, потом
|
| 208 |
+
из ниоткуда появился целый ответ одним блоком", без единого "живого" эффекта.
|
| 209 |
+
Теперь плейсхолдер НЕ удаляется здесь — он возвращается вызывающему коду
|
| 210 |
+
(_run_route), чтобы тот попробовал доправить в него финальный ответ от
|
| 211 |
+
следующей модели по цепочке напрямую, а не создавать новое сообщение.
|
| 212 |
+
Если ни одна дальнейшая модель не пригодится, _run_route сам аккуратно
|
| 213 |
+
уберёт этот плейсхолдер.
|
| 214 |
+
- Ошибка ПОСЛЕ показа части ответа — уже показанное не удаляется и не
|
| 215 |
+
подменяется другим ответом, в конец добавляется пометка о возможном обрыве;
|
| 216 |
+
возвращается (текст_ответа, None), как и при обычном успехе.
|
| 217 |
+
"""
|
| 218 |
+
import bot
|
| 219 |
+
state = bot.get_state(chat_id)
|
| 220 |
+
hist = state.setdefault("history", [])
|
| 221 |
+
ctx = state.get("ctx", deque())
|
| 222 |
+
|
| 223 |
+
sent_messages: list[Message] = []
|
| 224 |
+
full_text = ""
|
| 225 |
+
last_edit_ts = 0.0
|
| 226 |
+
last_edited_plain = ""
|
| 227 |
+
first_piece_ts: float | None = None
|
| 228 |
+
# pace_key/overall_start_ts/current_chunk_start_ts — см. lumen_typing_pace.py.
|
| 229 |
+
# overall_start_ts фиксируется ОДИН раз (для замера реальной скорости бэкенда
|
| 230 |
+
# целиком, даже если ответ займёт несколько сообщений), current_chunk_start_ts
|
| 231 |
+
# сбрасывается на каждое НОВОЕ сообщение (см. continuation ниже) — паттерн
|
| 232 |
+
# печати у каждого отдельного Telegram-сообщения свой, начинается заново.
|
| 233 |
+
pace_key = _typing_speed_key(provider, model_id)
|
| 234 |
+
overall_start_ts = time.monotonic()
|
| 235 |
+
current_chunk_start_ts = overall_start_ts
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
placeholder = await bot._tg_call(message.reply, "…", call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT)
|
| 239 |
+
if placeholder is None:
|
| 240 |
+
return None, None
|
| 241 |
+
sent_messages.append(placeholder)
|
| 242 |
+
|
| 243 |
+
# Обёртка ожидания первого куска: адаптивный предел + бегущие точки
|
| 244 |
+
# (см. _pieces_with_waiting_feedback). Дальше цикл не отличим от чтения
|
| 245 |
+
# исходного генератора напрямую — все существующие проверки кусков,
|
| 246 |
+
# утечек и троттлинга ниже работают без изменений.
|
| 247 |
+
piece_agen = _pieces_with_waiting_feedback(
|
| 248 |
+
piece_agen, placeholder,
|
| 249 |
+
first_chunk_limit=_model_first_chunk_limit(_model_speed_key(provider, model_id), bot.FIRST_CHUNK_TIMEOUT_SEC),
|
| 250 |
+
)
|
| 251 |
+
async for piece in piece_agen:
|
| 252 |
+
if not piece:
|
| 253 |
+
continue
|
| 254 |
+
if first_piece_ts is None:
|
| 255 |
+
first_piece_ts = time.monotonic()
|
| 256 |
+
full_text += piece
|
| 257 |
+
|
| 258 |
+
leak_kind = None
|
| 259 |
+
_scan_text = _leak_scan_window(full_text, piece)
|
| 260 |
+
if _detect_identity_leak(_scan_text):
|
| 261 |
+
leak_kind = "identity"
|
| 262 |
+
elif _detect_injected_payload_echo(_scan_text):
|
| 263 |
+
leak_kind = "payload_echo"
|
| 264 |
+
|
| 265 |
+
if leak_kind:
|
| 266 |
+
# Проверяем СРАЗУ после накопления куска и ДО любого edit_text ниже —
|
| 267 |
+
# на этот момент ни одно уже показанное пользователю сообщение ещё не
|
| 268 |
+
# содержит только что добавленный (утекающий) кусок текста, поэтому
|
| 269 |
+
# обрыв здесь гарантированно не даёт утечке "мигнуть" на экране хотя бы
|
| 270 |
+
# на долю секунды, в отличие от проверки уже после финальной правки.
|
| 271 |
+
tag = "identity-leak" if leak_kind == "identity" else "injection-echo"
|
| 272 |
+
log.warning(
|
| 273 |
+
'[%s] Stream %s/%s started leaking internal details/echoing an injected instruction — aborting the stream and showing a neutral reply instead of the partially accumulated text: %r', tag, provider, model_id, full_text[:500],
|
| 274 |
+
)
|
| 275 |
+
aclose = getattr(piece_agen, "aclose", None)
|
| 276 |
+
if aclose is not None:
|
| 277 |
+
with contextlib.suppress(Exception):
|
| 278 |
+
await aclose()
|
| 279 |
+
final_answer = _IDENTITY_LEAK_FALLBACK if leak_kind == "identity" else _INJECTED_PAYLOAD_ECHO_FALLBACK
|
| 280 |
+
await bot._tg_call(sent_messages[-1].edit_text, final_answer, parse_mode=None, call_timeout=15.0)
|
| 281 |
+
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 282 |
+
hist.append({"role": "assistant", "content": final_answer})
|
| 283 |
+
if len(hist) > bot.SHARED_HISTORY_MAX_LEN:
|
| 284 |
+
del hist[:-bot.SHARED_HISTORY_MAX_LEN]
|
| 285 |
+
ctx.clear()
|
| 286 |
+
bot._record_quota_usage(provider, model_id)
|
| 287 |
+
return final_answer, None
|
| 288 |
+
|
| 289 |
+
chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
|
| 290 |
+
# Финализируем все чанки кроме последнего (текущего, ещё растущего) —
|
| 291 |
+
# тот же алгоритм разбиения, что и для обычных (нестримленных) длинных ответов.
|
| 292 |
+
while len(chunks) > len(sent_messages):
|
| 293 |
+
idx = len(sent_messages) - 1
|
| 294 |
+
# НАЙДЕНО ПРИ АУДИТЕ СТРИМИНГА: раньше здесь СНАЧАЛА финализировался
|
| 295 |
+
# sent_messages[idx] полным edit_text, а ПОТОМ, если открыть сообщение-
|
| 296 |
+
# продолжение не удавалось, тот же самый edit_text вызывался ЕЩЁ РАЗ с
|
| 297 |
+
# тем же текстом плюс пометка — два сетевых вызова ради одного и того же
|
| 298 |
+
# результата в ветке отказа. Пробуем continuation ПЕРВЫМ, и финализируем
|
| 299 |
+
# idx ровно одним вызовом сразу с нужным текстом (с пометкой или без) —
|
| 300 |
+
# тот же итоговый результат для пользователя, но без лишнего HTTP-запроса
|
| 301 |
+
# к Telegram, когда continuation всё равно проваливается.
|
| 302 |
+
# bot.bot — инстанс aiogram Bot (в модуле имя bot занято самим модулем).
|
| 303 |
+
new_msg = await bot._tg_call(bot.bot.send_message, chat_id=message.chat.id, text="…", call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT)
|
| 304 |
+
if new_msg is None:
|
| 305 |
+
# Не удалось открыть сообщение под продолжен��е. ВАЖНО: sent_messages[idx]
|
| 306 |
+
# ещё НЕ финализирован — это НЕ то же самое, что общий except ниже
|
| 307 |
+
# (который считает, что sent_messages[-1] соответствует chunks[-1] —
|
| 308 |
+
# здесь это неверно, там уже другой, ещё не начатый кусок). Обрабатываем
|
| 309 |
+
# прямо тут, не давая общему except перезаписать корректно показанный
|
| 310 |
+
# текст чужим содержимым.
|
| 311 |
+
note = bot._t(message.chat.id, "stream_note_send_fail")
|
| 312 |
+
with contextlib.suppress(Exception):
|
| 313 |
+
await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]) + note, parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 314 |
+
final_answer = full_text.strip()
|
| 315 |
+
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 316 |
+
hist.append({"role": "assistant", "content": final_answer})
|
| 317 |
+
if len(hist) > bot.SHARED_HISTORY_MAX_LEN:
|
| 318 |
+
del hist[:-bot.SHARED_HISTORY_MAX_LEN]
|
| 319 |
+
ctx.clear()
|
| 320 |
+
bot._record_quota_usage(provider, model_id)
|
| 321 |
+
return final_answer, None
|
| 322 |
+
await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]), parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 323 |
+
sent_messages.append(new_msg)
|
| 324 |
+
last_edited_plain = ""
|
| 325 |
+
# Новое сообщение — новый "лист", печать в нём начинается с нуля.
|
| 326 |
+
current_chunk_start_ts = time.monotonic()
|
| 327 |
+
|
| 328 |
+
# Троттлинг: реальный edit_text не чаще ~раза в STREAM_EDIT_MIN_INTERVAL_SEC,
|
| 329 |
+
# иначе Telegram начинает отвечать 429 на слишком частые правки одного
|
| 330 |
+
# сообщения. Видимый текст — не всё, что уже накоплено (full_text), а
|
| 331 |
+
# срез, растущий по оценённой скорости печати этой модели (см.
|
| 332 |
+
# lumen_typing_pace.py) — реальному приходу кусков он "верит" только
|
| 333 |
+
# как верхней границе (min(...)): если модель прислала текст МЕДЛЕННЕЕ
|
| 334 |
+
# оценённой скорости, показывается всё, что реально пришло, без
|
| 335 |
+
# искусственного придерживания; лимитирует именно случай, когда бэкенд
|
| 336 |
+
# присылает крупными редкими кусками быстрее, чем "читалось" бы вслух.
|
| 337 |
+
now = time.monotonic()
|
| 338 |
+
target_full = chunks[-1] if chunks else ""
|
| 339 |
+
typing_speed = _get_typing_speed(pace_key)
|
| 340 |
+
reveal_len = min(len(target_full), max(0, int((now - current_chunk_start_ts) * typing_speed)))
|
| 341 |
+
current_chunk_text = target_full[:reveal_len]
|
| 342 |
+
if now - last_edit_ts >= bot.STREAM_EDIT_MIN_INTERVAL_SEC and current_chunk_text != last_edited_plain:
|
| 343 |
+
await bot._tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
|
| 344 |
+
last_edited_plain = current_chunk_text
|
| 345 |
+
last_edit_ts = now
|
| 346 |
+
|
| 347 |
+
if not full_text.strip():
|
| 348 |
+
# Стрим завершился, но не прислал ни одного символа текста — считаем
|
| 349 |
+
# попытку неудавшейся. Плейсхолдер НЕ удаляем (см. докстринг) — отдаём
|
| 350 |
+
# его вызывающему коду, вдруг пригодится для следующей модели.
|
| 351 |
+
return None, sent_messages[-1]
|
| 352 |
+
|
| 353 |
+
# Замер РЕАЛЬНОЙ скорости бэкенда — обязательно ДО фазы "довывода" ниже,
|
| 354 |
+
# иначе самим же добавленная пауза исказила бы будущую оценку скорости
|
| 355 |
+
# этой модели (см. докстринг record_observed_speed в lumen_typing_pace.py).
|
| 356 |
+
_record_typing_speed(pace_key, time.monotonic() - overall_start_ts, len(full_text))
|
| 357 |
+
# Замер задержки модели для умного роутера (см. lumen_model_speed.py) —
|
| 358 |
+
# рядом с замером скорости печати, из тех же меток времени. first_piece_ts
|
| 359 |
+
# здесь уже точно установлен (успех означает ≥1 кусок). Catch-up паузы
|
| 360 |
+
# сознательно ВКЛЮЧЕНЫ в total: для решения "кого став��ть первым" важна
|
| 361 |
+
# задержка, видимая пользователем, а не только время бэкенда.
|
| 362 |
+
_record_model_latency(
|
| 363 |
+
_model_speed_key(provider, model_id),
|
| 364 |
+
total_sec=time.monotonic() - overall_start_ts,
|
| 365 |
+
ttf_sec=(first_piece_ts - overall_start_ts) if first_piece_ts is not None else None,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# "Довывод" остатка последнего сообщения, который стрим уже прислал
|
| 369 |
+
# целиком, но пейсинг выше ещё не успел показать (частый случай для
|
| 370 |
+
# бэкендов, присылающих готовый текст одним большим SSE-куском в конце —
|
| 371 |
+
# см. lumen_typing_pace.py) — без этого пользователь увидел бы "…" почти
|
| 372 |
+
# до самого конца, а затем весь ответ разом. Ограничено по построению
|
| 373 |
+
# (см. catchup_reveal_steps) сверху STREAM_TYPING_MAX_CATCHUP_TICKS *
|
| 374 |
+
# STREAM_TYPING_TICK_SEC секунд — не тянет отправку ответа надолго.
|
| 375 |
+
final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
|
| 376 |
+
target_full = final_chunks[-1]
|
| 377 |
+
already_shown_len = len(last_edited_plain) if last_edited_plain and target_full.startswith(last_edited_plain) else 0
|
| 378 |
+
remaining_len = len(target_full) - already_shown_len
|
| 379 |
+
if remaining_len > 0:
|
| 380 |
+
typing_speed = _get_typing_speed(pace_key)
|
| 381 |
+
for step_len in _typing_catchup_steps(remaining_len, typing_speed, bot.STREAM_TYPING_TICK_SEC, bot.STREAM_TYPING_MAX_CATCHUP_TICKS):
|
| 382 |
+
await bot._typing_sleep(bot.STREAM_TYPING_TICK_SEC)
|
| 383 |
+
current_chunk_text = target_full[:already_shown_len + step_len]
|
| 384 |
+
if current_chunk_text != last_edited_plain:
|
| 385 |
+
await bot._tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
|
| 386 |
+
last_edited_plain = current_chunk_text
|
| 387 |
+
|
| 388 |
+
# Финальный сброс последнего сообщения — уже с полной HTML-конвертацией
|
| 389 |
+
# markdown (во время стрима сознательно показывался голый текст: частичный
|
| 390 |
+
# markdown при редактировании мог бы дать несбалансированные теги и сломать
|
| 391 |
+
# parse_mode=HTML на промежуточных правках).
|
| 392 |
+
final_text = final_chunks[-1]
|
| 393 |
+
# _edit_message_quietly уже инкапсулирует тот же HTML->plain fallback,
|
| 394 |
+
# что здесь раньше был продублирован вручную (см. аудит техдолга) —
|
| 395 |
+
# **kwargs проходит через неё прямиком в _tg_call, поэтому call_timeout
|
| 396 |
+
# передаётся без изменений в сигнатуре самой _edit_message_quietly.
|
| 397 |
+
await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
|
| 398 |
+
|
| 399 |
+
except Exception as exc:
|
| 400 |
+
if not full_text.strip():
|
| 401 |
+
# ВАЖНО: плейсхолдер больше НЕ удаляется здесь (в отличие от старой
|
| 402 |
+
# версии) — см. докстринг функции про переиспользование сообщения.
|
| 403 |
+
log.warning('[stream] Stream %s/%s failed before showing any content, falling back to a regular call: %s', provider, model_id, exc)
|
| 404 |
+
return None, (sent_messages[-1] if sent_messages else None)
|
| 405 |
+
log.warning('[stream] Stream %s/%s failed after partially showing the response, finishing as-is: %s', provider, model_id, exc)
|
| 406 |
+
if _detect_identity_leak(full_text):
|
| 407 |
+
# На практике сюда почти невозможно попасть (см. проверку сразу после
|
| 408 |
+
# каждого куска выше) — оставлено как последняя страховка на случай бага
|
| 409 |
+
# в основной проверке, а не полагаясь только на один рубеж.
|
| 410 |
+
log.warning('[identity-leak] Leak caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
|
| 411 |
+
full_text = _IDENTITY_LEAK_FALLBACK
|
| 412 |
+
with contextlib.suppress(Exception):
|
| 413 |
+
await bot._tg_call(sent_messages[-1].edit_text, _IDENTITY_LEAK_FALLBACK, parse_mode=None, call_timeout=15.0)
|
| 414 |
+
elif _detect_injected_payload_echo(full_text):
|
| 415 |
+
log.warning('[injection-echo] Injected-instruction echo caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
|
| 416 |
+
full_text = _INJECTED_PAYLOAD_ECHO_FALLBACK
|
| 417 |
+
with contextlib.suppress(Exception):
|
| 418 |
+
await bot._tg_call(sent_messages[-1].edit_text, _INJECTED_PAYLOAD_ECHO_FALLBACK, parse_mode=None, call_timeout=15.0)
|
| 419 |
+
else:
|
| 420 |
+
with contextlib.suppress(Exception):
|
| 421 |
+
chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
|
| 422 |
+
final_text = chunks[-1] if chunks else full_text
|
| 423 |
+
note = bot._t(message.chat.id, "stream_note_interrupted")
|
| 424 |
+
await bot._tg_call(sent_messages[-1].edit_text, _md_to_html(final_text + note), parse_mode=ParseMode.HTML, call_timeout=15.0)
|
| 425 |
+
finally:
|
| 426 |
+
aclose = getattr(piece_agen, "aclose", None)
|
| 427 |
+
if aclose is not None:
|
| 428 |
+
with contextlib.suppress(Exception):
|
| 429 |
+
await aclose()
|
| 430 |
+
|
| 431 |
+
# Пустой full_text сюда не доходит (проверка выше возвращает None раньше),
|
| 432 |
+
# поэтому фолбэка "Empty response" больше нет — мёртвый код убран.
|
| 433 |
+
final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
|
| 434 |
+
hist.append({"role": "user", "content": _history_user_text(user_text)})
|
| 435 |
+
hist.append({"role": "assistant", "content": final_answer})
|
| 436 |
+
if len(hist) > bot.SHARED_HISTORY_MAX_LEN:
|
| 437 |
+
del hist[:-bot.SHARED_HISTORY_MAX_LEN]
|
| 438 |
+
ctx.clear()
|
| 439 |
+
bot._record_quota_usage(provider, model_id)
|
| 440 |
+
return final_answer, None
|
| 441 |
+
|
| 442 |
+
async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message, model_id: str) -> tuple[str | None, Message | None]:
|
| 443 |
+
"""Тонкая обёртка над _run_streaming_reply для Gemini: строит contents/config,
|
| 444 |
+
специфичные для Gemini API, и передаёт их в общую (провайдер-агностичную)
|
| 445 |
+
реализацию стриминга. Возвращает (ответ, плейсхолдер) — см. _run_streaming_reply."""
|
| 446 |
+
import bot
|
| 447 |
+
conf = GEMINI_MODELS.get(model_id, {})
|
| 448 |
+
if not conf.get("stream", True):
|
| 449 |
+
return None, None
|
| 450 |
+
contents = await bot._build_gemini_turn_contents(chat_id, user_text)
|
| 451 |
+
call_contents, gconfig = bot._build_gemini_call_config(model_id, contents)
|
| 452 |
+
piece_agen = _gemini_stream_pieces(model_id, call_contents, gconfig)
|
| 453 |
+
return await _run_streaming_reply(chat_id, user_text, message, provider="gemini", model_id=model_id, piece_agen=piece_agen)
|
| 454 |
+
|
| 455 |
+
async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Message, model_id: str) -> tuple[str | None, Message | None]:
|
| 456 |
+
"""Тонкая обёртка над _run_streaming_reply для OpenRouter — тот же принцип,
|
| 457 |
+
что и _try_gemini_streaming, но с SSE-стримингом через chat/completions."""
|
| 458 |
+
import bot
|
| 459 |
+
messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
|
| 460 |
+
# Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
|
| 461 |
+
piece_agen = bot._openrouter_stream_pieces(model_id, messages)
|
| 462 |
+
return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen)
|