File size: 31,321 Bytes
b5edc97
0b65b92
b5edc97
 
 
 
 
 
 
 
6472592
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398ccda
0803c88
b5edc97
 
 
 
 
 
 
5c09528
 
b5edc97
 
 
 
 
0b65b92
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
0b65b92
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6472592
 
 
 
 
 
 
 
62c1832
 
 
6472592
 
 
62c1832
 
fb2a2f0
 
 
b5edc97
fb2a2f0
b5edc97
 
 
 
6472592
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
36e6ab0
 
 
 
 
 
 
 
 
 
 
b5edc97
3c6ee8d
 
 
 
 
 
 
 
 
 
b5edc97
 
36e6ab0
b5edc97
 
 
6472592
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36e6ab0
b5edc97
 
 
 
 
 
 
3c6ee8d
 
 
b5edc97
36e6ab0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6472592
36e6ab0
 
9b9fc79
 
 
 
 
 
 
 
36e6ab0
 
 
 
 
 
9b9fc79
b5edc97
 
dd4c939
b5edc97
0b65b92
b5edc97
 
 
 
 
 
0b2d333
 
 
 
 
 
 
b5edc97
 
 
5c09528
b5edc97
 
5c09528
 
 
 
b5edc97
 
 
 
 
 
5fecfef
 
 
b5edc97
0b65b92
b5edc97
 
 
 
 
 
 
dd4c939
 
 
 
5c09528
b5edc97
5c09528
 
 
 
 
b5edc97
 
 
 
 
 
 
 
 
 
0b65b92
b5edc97
 
27a3176
b5edc97
 
 
 
 
 
 
 
 
a348a03
 
566949b
b5edc97
 
 
0b2d333
 
 
0b65b92
b5edc97
 
0b65b92
 
b5edc97
 
0b65b92
b5edc97
 
 
 
 
 
5f80ef1
 
5a68db4
566949b
b5edc97
 
0b2d333
 
 
 
 
 
b5edc97
 
5c09528
 
b5edc97
5c09528
b5edc97
 
5c09528
 
b5edc97
0b2d333
 
 
 
b5edc97
 
 
 
0b65b92
b5edc97
 
0b65b92
b5edc97
 
 
 
 
 
 
0b65b92
b5edc97
 
 
 
0b2d333
5c09528
b5edc97
 
 
c019423
 
 
 
b5edc97
 
c019423
b5edc97
0b65b92
b5edc97
0b2d333
 
 
 
 
 
 
 
b5edc97
 
 
5f80ef1
 
 
dd4c939
 
 
168044d
 
 
 
62c1832
 
 
7bb8c4a
 
 
 
 
 
dd4c939
 
0b65b92
b5edc97
5fecfef
b5edc97
 
 
0b65b92
b5edc97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f7686fb
 
 
 
b5edc97
 
 
 
 
 
0b65b92
b5edc97
0803c88
 
b5edc97
 
5a68db4
 
566949b
b5edc97
 
 
dd4c939
0b65b92
b5edc97
 
 
 
 
 
6472592
dd4c939
b5edc97
dd4c939
0b65b92
b5edc97
 
 
6472592
dd4c939
9b9fc79
dd4c939
5fecfef
9b9fc79
5fecfef
 
9b9fc79
6472592
dd4c939
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
"""
lumen_streaming.py — провайдер-агностичный стриминг (плейсхолдер, точки, троттлинг правок, скраб утечек на чанк, catch-up). Связи с bot.py — только через отложенный `import bot`; точки подмены для тестов читаются через `bot.` в момент вызова.
"""
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import time
from typing import Any
import aiohttp
from aiogram.enums import ParseMode
from aiogram.types import Message

from lumen_formatting import _md_to_html, _split_text_chunks
from lumen_message_parse import _history_user_text
from lumen_model_speed import (
    speed_key as _model_speed_key,
    record_response as _record_model_latency,
    first_chunk_limit_sec as _model_first_chunk_limit,
)
from lumen_router_config import GEMINI_MODELS
from lumen_security import (
    _leak_scan_window,
    _IDENTITY_LEAK_FALLBACK,
    _INJECTED_PAYLOAD_ECHO_FALLBACK,
    _detect_injected_payload_echo,
    _detect_garbled_mix,
    _is_garbled_echo,
    _detect_identity_leak,
    _scrub_identity_leak,
)
from lumen_typing_pace import (
    speed_key as _typing_speed_key,
    record_observed_speed as _record_typing_speed,
    catchup_reveal_steps as _typing_catchup_steps,
    blend_arrival_speed as _typing_arrival_update,
    display_speed_for as _typing_display_speed,
)

log = logging.getLogger("bot")

async def _tick_waiting_dots(placeholder: Message) -> None:
    """Точки в плейсхолдере до первого куска: первый кадр после паузы (быстрые модели анимации не видят), дальше кадр по тику. Правки через _tg_call с глотанием ошибок, отмена — штатная остановка."""
    import bot
    try:
        await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
        frame_idx = 0
        while True:
            with contextlib.suppress(Exception):
                await bot._tg_call(placeholder.edit_text, bot._DOTS_FRAMES[frame_idx % len(bot._DOTS_FRAMES)], parse_mode=None, call_timeout=15.0)
            frame_idx += 1
            await bot._dots_sleep(bot._DOTS_TICK_SEC)
    except asyncio.CancelledError:
        raise


async def _pieces_with_waiting_feedback(piece_agen, placeholder: Message, *, first_chunk_limit: float):
    """Генератор + ожидание первого куска: адаптивный предел (зависшая попытка — TimeoutError, плейсхолдер уйдёт следующей модели) и анимация точек строго до первого yield. Пустой стрим — просто конец."""
    dots_task = asyncio.create_task(_tick_waiting_dots(placeholder))
    try:
        try:
            first = await asyncio.wait_for(piece_agen.__anext__(), timeout=first_chunk_limit)
        except StopAsyncIteration:
            return
        finally:
            dots_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await dots_task
        yield first
        async for piece in piece_agen:
            yield piece
    finally:
        aclose = getattr(piece_agen, "aclose", None)
        if aclose is not None:
            with contextlib.suppress(Exception):
                await aclose()

def _remaining_budget(deadline: float | None) -> float:
    """Сколько секунд осталось до бюджета маршрута (float('inf') — потолка нет)."""
    if deadline is None:
        return float("inf")
    return max(0.0, deadline - time.monotonic())

def _stream_wait(bot: Any, deadline: float | None) -> float:
    """Таймаут на ОДНУ операцию стрима: не больше межкускового капа и не больше
    остатка бюджета маршрута. Раньше ожидание шло в обход бюджета, и кусок мог ждать
    STREAM_CHUNK_TIMEOUT_SEC уже после его исчерпания (аудит 26.09.2026). Значение
    меньше 0.1с не опускаем: это всё равно мгновенный TimeoutError."""
    return max(0.1, min(bot.STREAM_CHUNK_TIMEOUT_SEC, _remaining_budget(deadline)))

async def _gemini_stream_pieces(model_id: str, call_contents: list, gconfig, *, deadline: float | None = None):
    """Куски от Gemini (тонкая обёртка над generate_content_stream).

    В google-genai 2.27.0 это async def: вызов надо ждать (пример из SDK:
    `async for chunk in await client.aio.models.generate_content_stream(...)`).
    Реальную сетевую жду ловлю ниже: первый кусок идёт через __anext__ под тем же _stream_wait."""
    import bot
    stream = await bot.client.aio.models.generate_content_stream(model=model_id, contents=call_contents, config=gconfig)
    stream_iter = stream.__aiter__()
    try:
        while True:
            try:
                chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=_stream_wait(bot, deadline))
            except StopAsyncIteration:
                break
            try:
                piece = getattr(chunk, "text", "") or ""
            except Exception:
                piece = ""
            if piece:
                yield piece
    finally:
        aclose = getattr(stream, "aclose", None)
        if aclose is not None:
            with contextlib.suppress(Exception):
                await aclose()

async def _sse_pieces(
    session, url: str, headers: dict[str, str], payload: dict, *, err_cls, provider_label: str,
    deadline: float | None = None,
):
    """Общий разбор OpenAI-совместимого SSE для OpenRouter и Groq (оба дают
    `data: {"choices":[{"delta":{"content":...}}]}` и финальный `data: [DONE]`).

    Раньше это были две копии по 45 строк, расходившиеся деталями: у Groq не было
    теста на HTTP-ошибку и на ошибку внутри чанка (аудит 26.09.2026). Ошибка внутри
    чанка (HTTP 200, стрим уже открыт) поднимается: ранний сбой — откат на
    следующую модель, поздний — пометка "соединение прервалось"."""
    import bot
    post_cm = session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=None, connect=12.0))
    try:
        # Заголовки без total висели вечно и держали lock чата до рестарта (аудит 29.09.2026),
        # поэтому вход ждём не дольше попытки маршрута, а тело стрима — по-прежнему покусочно.
        resp = await asyncio.wait_for(post_cm.__aenter__(), timeout=bot._attempt_timeout(bot, deadline))
    except BaseException:
        with contextlib.suppress(Exception):
            await post_cm.__aexit__(None, None, None)
        raise
    try:
        if resp.status >= 400:
            body = await resp.read()
            raise err_cls(f"HTTP {resp.status}: {body[:300]!r}", status_code=resp.status)
        line_iter = resp.content.__aiter__()
        while True:
            try:
                raw_line = await asyncio.wait_for(line_iter.__anext__(), timeout=_stream_wait(bot, deadline))
            except StopAsyncIteration:
                break
            line = raw_line.decode("utf-8", errors="ignore").strip()
            if not line or not line.startswith("data:"):
                continue
            data_str = line[len("data:"):].strip()
            if data_str == "[DONE]":
                break
            try:
                obj = json.loads(data_str)
            except Exception:
                continue
            err_obj = obj.get("error")
            if err_obj:
                err_msg = err_obj.get("message") if isinstance(err_obj, dict) else str(err_obj)
                err_code = err_obj.get("code") if isinstance(err_obj, dict) else None
                raise err_cls(err_msg or f"{provider_label} returned an error in the stream body", status_code=err_code)
            choices = obj.get("choices") or []
            if not choices:
                continue
            delta = choices[0].get("delta") or {}
            piece = delta.get("content") or ""
            if piece:
                yield piece
    finally:
        with contextlib.suppress(Exception):
            await post_cm.__aexit__(None, None, None)

async def _openrouter_stream_pieces(model_id: str, messages: list[dict], *, deadline: float | None = None):
    """Куски от OpenRouter: SSE через общий _sse_pieces, таймаут на строку — тот же
    STREAM_CHUNK_TIMEOUT_SEC, но не дольше остатка бюджета маршрута."""
    import bot
    if not bot.OPENROUTER_API_KEY:
        raise bot.OpenRouterAPIError("OPENROUTER_API_KEY is not set")
    headers = {
        "Authorization": f"Bearer {bot.OPENROUTER_API_KEY}",
        "HTTP-Referer": bot.OPENROUTER_HTTP_REFERER,
        "X-OpenRouter-Title": bot.OPENROUTER_TITLE,
        "Content-Type": "application/json",
    }
    session = await bot._get_http_session()
    async for piece in _sse_pieces(
        session, f"{bot.OPENROUTER_BASE_URL}/chat/completions", headers,
        {"model": model_id, "messages": messages, "stream": True},
        err_cls=bot.OpenRouterAPIError, provider_label="OpenRouter", deadline=deadline,
    ):
        yield piece

async def _groq_stream_pieces(model_id: str, messages: list[dict], *, deadline: float | None = None):
    """Куски от Groq: тот же OpenAI-SSE через общий _sse_pieces (Groq —
    OpenAI-совместимый API), таймаут на строку — как у OpenRouter."""
    import bot
    if not bot.GROQ_API_KEY:
        raise bot.GroqAPIError("GROQ_API_KEY is not set")
    headers = {
        "Authorization": f"Bearer {bot.GROQ_API_KEY}",
        "Content-Type": "application/json",
    }
    session = await bot._get_http_session()
    async for piece in _sse_pieces(
        session, f"{bot.GROQ_BASE_URL}/chat/completions", headers,
        {"model": model_id, "messages": messages, "stream": True},
        err_cls=bot.GroqAPIError, provider_label="Groq", deadline=deadline,
    ):
        yield piece

async def _run_streaming_reply(
    chat_id: int, user_text: str, message: Message, *, provider: str, model_id: str, piece_agen,
    deadline: float | None = None,
) -> tuple[str | None, Message | None]:
    """Стриминг: генератор кусков + плейсхолдер, чанкинг, троттлинг, обрыв при утечке/инъекции, HTML-финал, запись в историю. Возвращает (ответ, плейсхолдер): успех — (текст, None); сбой до показа — (None, плейсхолдер для следующей модели); сбой после — (показанное + пометка, None)."""
    import bot
    state = bot.get_state(chat_id)
    hist = state.setdefault("history", [])

    sent_messages: list[Message] = []
    full_text = ""
    # Базис последнего разбиения: чанки кроме последнего стабильны, поэтому
    # _split_text_chunks пересчитываем только при заметном приросте, а не на
    # каждый кусок (иначе O(n²) в loop при посимвольном стриме).
    split_basis = ""
    chunks: list[str] = []
    edit_fail_streak = 0
    edits_dead = False
    last_edit_ts = 0.0
    last_edited_plain = ""
    first_piece_ts: float | None = None
    # Замеры скорости/латентности — см. lumen_typing_pace.py (overall один раз на весь ответ, arrival — заново на каждое новое сообщение).
    pace_key = _typing_speed_key(provider, model_id)
    overall_start_ts = time.monotonic()
    # reveal_base_ts — момент ПЕРВОГО куска, а не старта стрима: иначе после долгой тишины формула сразу разрешала показать всё накопленное разом. last_piece_ts/arrival_ewma — замер реального темпа прихода.
    reveal_base_ts: float | None = None
    last_piece_ts: float | None = None
    arrival_ewma: float | None = None

    try:
        placeholder = await bot._tg_call(message.reply, "…", call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT)
        if placeholder is None:
            return None, None
        sent_messages.append(placeholder)
        # Попытка к модели состоялась (запрос уже ушёл в генератор кусков);
        # пустой считают только ранний обрыв до показа — частичный ответ показан.
        bot._record_stats_event("model_attempts")

        # Дальше цикл читает генератор напрямую — проверки кусков/утечек/троттлинга ниже без изменений.
        piece_agen = _pieces_with_waiting_feedback(
            piece_agen, placeholder,
            first_chunk_limit=_model_first_chunk_limit(_model_speed_key(provider, model_id), bot.FIRST_CHUNK_TIMEOUT_SEC),
        )
        async for piece in piece_agen:
            if not piece:
                continue
            # Общий бюджет маршрута — и на стрим тоже: капающий по куску раз в 29с стрим
            # иначе держал бы lock чата далеко за ROUTE_TOTAL_BUDGET_SEC (найдено внешним аудитом).
            if deadline is not None and time.monotonic() > deadline:
                raise bot.RouteBudgetExceededError([f"{provider}:{model_id}"])
            now_piece = time.monotonic()
            if first_piece_ts is None:
                first_piece_ts = now_piece
                reveal_base_ts = now_piece
            else:
                arrival_ewma = _typing_arrival_update(arrival_ewma, len(piece), now_piece - (last_piece_ts or now_piece))
            last_piece_ts = now_piece
            full_text += piece

            leak_kind = None
            _scan_text = _leak_scan_window(full_text, piece)
            if _detect_identity_leak(_scan_text):
                leak_kind = "identity"
            elif _detect_injected_payload_echo(_scan_text):
                leak_kind = "payload_echo"

            if leak_kind:
                # Проверяем ДО edit_text — утечка не успевает "мигнуть" на экране.
                tag = "identity-leak" if leak_kind == "identity" else "injection-echo"
                log.warning(
                    '[%s] Stream %s/%s started leaking internal details/echoing an injected instruction — aborting the stream and showing a neutral reply instead (accumulated_len=%d)', tag, provider, model_id, len(full_text),
                )
                aclose = getattr(piece_agen, "aclose", None)
                if aclose is not None:
                    with contextlib.suppress(Exception):
                        await aclose()
                final_answer = _IDENTITY_LEAK_FALLBACK if leak_kind == "identity" else _INJECTED_PAYLOAD_ECHO_FALLBACK
                await bot._tg_call(sent_messages[-1].edit_text, final_answer, parse_mode=None, call_timeout=15.0)
                hist.append({"role": "user", "content": _history_user_text(user_text)})
                hist.append({"role": "assistant", "content": final_answer})
                # Обрезка с саммари старого (см. _trim_history), а не молчаливый срез.
                await bot._trim_history(hist)
                bot._trim_combined(state)
                bot._record_quota_usage(provider, model_id)
                return final_answer, None

            if not chunks or len(full_text) - len(split_basis) >= 1000:
                chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
                split_basis = full_text
            # Чанки кроме последнего (растущего) финализируем тем же разбиением, что у нестримленных ответов.
            while len(chunks) > len(sent_messages):
                idx = len(sent_messages) - 1
                # Continuation открываем ПЕРВЫМ и финализируем одним вызовом — раньше было два edit_text при отказе (лишний HTTP-запрос).
                # bot.bot — инстанс aiogram Bot (имя bot занято модулем).
                new_msg = await bot._tg_call(bot.bot.send_message, chat_id=message.chat.id, text="…", call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT)
                if new_msg is None:
                    # idx ещё НЕ финализирован — не даём общему except перезаписать показанный текст чужим (там инвариант "sent[-1] == chunks[-1]" уже неверен).
                    note = bot._t(message.chat.id, "stream_note_send_fail")
                    with contextlib.suppress(Exception):
                        await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]) + note, parse_mode=ParseMode.HTML, call_timeout=15.0)
                    final_answer = full_text.strip()
                    hist.append({"role": "user", "content": _history_user_text(user_text)})
                    hist.append({"role": "assistant", "content": final_answer})
                    # Обрезка с саммари (см. _trim_history): молчаливый срез оставлял
                    # саммаризацию только нестриминговым путям (аудит 26.09.2026).
                    await bot._trim_history(hist)
                    bot._trim_combined(state)
                    bot._record_quota_usage(provider, model_id)
                    return final_answer, None
                if not edits_dead:
                    edit_ok = await bot._tg_call(sent_messages[idx].edit_text, _md_to_html(chunks[idx]), parse_mode=ParseMode.HTML, call_timeout=15.0)
                    # Серия неуспешных правок подряд — сообщение снесли посреди стрима:
                    # дальше не правим, в финале досылаем ответ новым сообщением.
                    edit_fail_streak = 0 if edit_ok is not None else edit_fail_streak + 1
                    edits_dead = edit_fail_streak >= 3
                sent_messages.append(new_msg)
                last_edited_plain = ""
                # Новое сообщение — новый "лист", показ в нём начинается с нуля (темп прихода бэкенда тот же).
                reveal_base_ts = time.monotonic()

            # Троттлинг edit_text (~раз в интервал, иначе 429) + видимый срез по измеренному темпу прихода (верхняя граница — реально пришедшее).
            now = time.monotonic()
            target_full = chunks[-1] if chunks else ""
            typing_speed = _typing_display_speed(arrival_ewma, pace_key)
            reveal_len = min(len(target_full), max(0, int((now - (reveal_base_ts or now)) * typing_speed)))
            current_chunk_text = target_full[:reveal_len]
            if not edits_dead and now - last_edit_ts >= bot.STREAM_EDIT_MIN_INTERVAL_SEC and current_chunk_text != last_edited_plain:
                edit_ok = await bot._tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
                edit_fail_streak = 0 if edit_ok is not None else edit_fail_streak + 1
                edits_dead = edit_fail_streak >= 3
                last_edited_plain = current_chunk_text
                last_edit_ts = now

        if not full_text.strip():
            # Пустой стрим — неудача, но плейсхолдер отдаём вызывающему коду (см. докстринг).
            return None, sent_messages[-1]

        # Замеры — ДО довывода (пауза не должна искажать оценку), catch-up паузы в total модели — сознательно (важна видимая задержка).
        _record_typing_speed(pace_key, time.monotonic() - overall_start_ts, len(full_text))
        _record_model_latency(
            _model_speed_key(provider, model_id),
            total_sec=time.monotonic() - overall_start_ts,
            ttf_sec=(first_piece_ts - overall_start_ts) if first_piece_ts is not None else None,
        )

        # "Довывод" остатка: бэкенды одним куском в конце показывали бы "…" до самого финала. Ограничено catchup-лимитом сверху.
        final_chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
        target_full = final_chunks[-1]
        already_shown_len = len(last_edited_plain) if last_edited_plain and target_full.startswith(last_edited_plain) else 0
        remaining_len = len(target_full) - already_shown_len
        if remaining_len > 0 and not edits_dead:
            typing_speed = _typing_display_speed(arrival_ewma, pace_key)
            for step_len in _typing_catchup_steps(remaining_len, typing_speed, bot.STREAM_TYPING_TICK_SEC, bot.STREAM_TYPING_MAX_CATCHUP_TICKS):
                await bot._typing_sleep(bot.STREAM_TYPING_TICK_SEC)
                current_chunk_text = target_full[:already_shown_len + step_len]
                # Довывод правится тем же троттлингом, что основной цикл: тик
                # короче интервала правок, иначе серия быстро ловти 429.
                now = time.monotonic()
                if current_chunk_text != last_edited_plain and now - last_edit_ts >= bot.STREAM_EDIT_MIN_INTERVAL_SEC:
                    await bot._tg_call(sent_messages[-1].edit_text, current_chunk_text, parse_mode=None, call_timeout=15.0)
                    last_edited_plain = current_chunk_text
                    last_edit_ts = now

        # Финал — с полной HTML-конвертацией (во время стрима голый текст: частичный markdown дал бы несбалансированные теги).
        final_text = final_chunks[-1]
        if edits_dead:
            # Правки умирали серией — сообщение снесли посреди стрима: весь ответ
            # досылаем новым сообщением (_send_text внутри режет на части сам),
            # иначе пользователь его не увидит.
            await bot._safe_reply(message, full_text.strip())
        else:
            # HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
            await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)

    except Exception as exc:
        if not full_text.strip():
            # 429 до первого куска — в /stats и ненадолго из роута. Суточный лимит
            # держим до утра, минутный всплеск — короткая остывка (иначе один 429
            # запирал модель до полуночи — враждебное ревью 27.09.2026).
            try:
                _txt = bot._error_text(exc).strip() or exc.__class__.__name__
                if bot._classify_model_error(bot._error_status(exc, _txt), _txt) == "rate_limit":
                    # Дневную квоту опознаём предикатом провайдера, как текстовый
                    # путь: общий account-wide ловит только OpenRouter-тексты.
                    _daily = bot._is_gemini_daily_quota(_txt) if provider == "gemini" else bot._is_account_wide_or_rate_limit(_txt.lower())
                    if _daily:
                        bot._mark_quota_exhausted(provider, model_id)
                    else:
                        bot._mark_rate_limited(provider, model_id)
                else:
                    # 503/404 и прочие "недоступна" — остывка на тех же условиях
                    # (прод 05.10.2026: без неё пять моделей 3.x повторялись на
                    # каждом сообщении, по 22 секунды попытки впустую).
                    from lumen_routes import _mark_model_unavailable
                    _mark_model_unavailable(provider, model_id, exc)
            except Exception:
                pass
            # Плейсхолдер НЕ удаляем — возвращаем для переиспользования (см. докстринг).
            log.warning('[stream] Stream %s/%s failed before showing any content, falling back to a regular call: %s', provider, model_id, exc)
            bot._record_stats_event("model_failures")
            return None, (sent_messages[-1] if sent_messages else None)
        log.warning('[stream] Stream %s/%s failed after partially showing the response, finishing as-is: %s', provider, model_id, exc)
        if _detect_identity_leak(full_text):
            # Последняя страховка (основная проверка — на каждый кусок выше).
            log.warning('[identity-leak] Leak caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
            full_text = _IDENTITY_LEAK_FALLBACK
            with contextlib.suppress(Exception):
                await bot._tg_call(sent_messages[-1].edit_text, _IDENTITY_LEAK_FALLBACK, parse_mode=None, call_timeout=15.0)
        elif _detect_injected_payload_echo(full_text):
            log.warning('[injection-echo] Injected-instruction echo caught by the fallback guard (%s_stream_exception_path): %r', provider, full_text[:500])
            full_text = _INJECTED_PAYLOAD_ECHO_FALLBACK
            with contextlib.suppress(Exception):
                await bot._tg_call(sent_messages[-1].edit_text, _INJECTED_PAYLOAD_ECHO_FALLBACK, parse_mode=None, call_timeout=15.0)
        else:
            with contextlib.suppress(Exception):
                chunks = _split_text_chunks(full_text, bot.TG_MAX_LEN)
                final_text = chunks[-1] if chunks else full_text
                note = bot._t(message.chat.id, "stream_note_interrupted")
                await bot._tg_call(sent_messages[-1].edit_text, _md_to_html(final_text + note), parse_mode=ParseMode.HTML, call_timeout=15.0)
            # Частичный ответ уже показан и ляжет в историю ниже — запоминаем, что
            # его оборвало: слово «продолжи» добьёт с места обрыва (_continue_after_interrupt).
            state["interrupted"] = True
            bot.mark_state_dirty(chat_id)
    finally:
        aclose = getattr(piece_agen, "aclose", None)
        if aclose is not None:
            with contextlib.suppress(Exception):
                await aclose()

    # Пустой full_text сюда не доходит — ветки "Empty response" нет.
    final_answer = _scrub_identity_leak(full_text.strip(), source=f"{provider}_stream_final:{model_id}")
    bot._record_model_outcome(provider, model_id,
                              bad=_detect_garbled_mix(full_text.strip()) and not _is_garbled_echo(full_text.strip(), user_text))
    hist.append({"role": "user", "content": _history_user_text(user_text)})
    hist.append({"role": "assistant", "content": final_answer})
    # Тот же _trim_history, что в нестриминговых путях (см. комментарий выше).
    await bot._trim_history(hist)
    bot._trim_combined(state)
    bot._record_quota_usage(provider, model_id)
    return final_answer, None

async def _try_gemini_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None) -> tuple[str | None, Message | None]:
    """Обёртка _run_streaming_reply для Gemini (строит contents/config)."""
    import bot
    conf = GEMINI_MODELS.get(model_id, {})
    if not conf.get("stream", True):
        return None, None
    contents = await bot._build_gemini_turn_contents(chat_id, user_text)
    call_contents, gconfig = bot._build_gemini_call_config(model_id, contents)
    piece_agen = _gemini_stream_pieces(model_id, call_contents, gconfig, deadline=deadline)
    return await _run_streaming_reply(chat_id, user_text, message, provider="gemini", model_id=model_id, piece_agen=piece_agen, deadline=deadline)

async def _try_openrouter_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None) -> tuple[str | None, Message | None]:
    """Обёртка _run_streaming_reply для OpenRouter (SSE через chat/completions)."""
    import bot
    messages = bot._build_openrouter_turn_messages(chat_id, user_text, model_id)
    # Генератор — через bot.: тесты подменяют bot._openrouter_stream_pieces фейком.
    piece_agen = bot._openrouter_stream_pieces(model_id, messages, deadline=deadline)
    return await _run_streaming_reply(chat_id, user_text, message, provider="openrouter", model_id=model_id, piece_agen=piece_agen, deadline=deadline)

async def _try_groq_streaming(chat_id: int, user_text: str, message: Message, model_id: str, *, deadline: float | None = None) -> tuple[str | None, Message | None]:
    """Обёртка _run_streaming_reply для Groq (SSE через chat/completions). Историю режем тем же _trim_messages_for_groq, что обычный путь, — иначе стрим-проба падает с 413 ещё до цепочки."""
    import bot
    from lumen_routes import _trim_messages_for_groq
    messages = _trim_messages_for_groq(bot._build_openrouter_turn_messages(chat_id, user_text, model_id))
    # Генератор — через bot.: тесты подменяют bot._groq_stream_pieces фейком.
    piece_agen = bot._groq_stream_pieces(model_id, messages, deadline=deadline)
    return await _run_streaming_reply(chat_id, user_text, message, provider="groq", model_id=model_id, piece_agen=piece_agen, deadline=deadline)