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
Files changed (2) hide show
  1. bot.py +26 -415
  2. 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
- async def _tick_waiting_dots(placeholder: Message) -> None:
3296
- """Бегущие точки в плейсхолдере, пока стрим не прислал первый кусок.
3297
- Первый кадр — только после _DOTS_START_AFTER_SEC тишины (быстрые модели
3298
- анимации не видят вообще), дальше — кадр каждые _DOTS_TICK_SEC. Правки идут
3299
- через _tg_call (ошибки глотаются — плейсхолдер могли удалить/переиспользовать
3300
- параллельно), отмена задачи — штатный путь остановки после первого куска."""
3301
- try:
3302
- await _dots_sleep(_DOTS_START_AFTER_SEC)
3303
- frame_idx = 0
3304
- while True:
3305
- with contextlib.suppress(Exception):
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)