File size: 4,241 Bytes
00b6f48
5f80ef1
 
 
00b6f48
 
 
 
5f80ef1
f40e9a4
 
0b65b92
00b6f48
 
5f80ef1
00b6f48
 
 
 
 
 
 
 
 
 
5f80ef1
00b6f48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453b05f
 
 
00b6f48
 
453b05f
00b6f48
453b05f
 
 
00b6f48
 
 
 
 
 
 
 
 
 
 
0b65b92
00b6f48
 
 
 
5fcacff
 
 
 
 
00b6f48
5fcacff
 
 
 
 
 
 
 
00b6f48
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
"""
lumen_model_speed.py — самокалибрующаяся оценка задержек моделей.

Прод 17.09.2026: голова цепочки тормозила 96с, а роутер ставил её первой. Поэтому замер по факту + EMA, новичок с нейтрального приора. EMA только в памяти.
"""

from __future__ import annotations

# Ключ provider:model_id — из lumen_typing_pace, чтобы не плодить два форматтера.
from lumen_typing_pace import speed_key

# Тот же компромисс стабильности/подстройки, что в typing_pace.
_EMA_ALPHA = 0.3

# Приор нейтрален: новичок без замеров не взлетает в голову и не падает в хвост.
_DEFAULT_TTF_SEC = 10.0
_DEFAULT_TOTAL_SEC = 25.0

# Границы-зажимы ДО усреднения (тот же приём, что clamp в record_observed_speed):
# один выброс не должен утащить EMA в небеса или в пол.
_MIN_TTF_SEC = 1.0
_MAX_TTF_SEC = 90.0
_MIN_TOTAL_SEC = 3.0
_MAX_TOTAL_SEC = 120.0

# Запас на разовое зависание первого куска, а не вечное ожидание.
_TTF_ABANDON_MULT = 2.5

_latency_ema: dict[str, tuple[float, float]] = {}


def expected_ttf_sec(key: str) -> float:
    """Ожидаемое время до первого куска — EMA или приор для новой модели."""
    ttf = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[0]
    return max(_MIN_TTF_SEC, min(_MAX_TTF_SEC, ttf))


def expected_total_sec(key: str) -> float:
    """Ожидаемое полное время ответа — EMA или приор для новой модели."""
    total = _latency_ema.get(key, (_DEFAULT_TTF_SEC, _DEFAULT_TOTAL_SEC))[1]
    return max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total))


def record_response(key: str, *, total_sec: float, ttf_sec: float | None = None) -> None:
    """Учесть один УСПЕШНЫЙ ответ. Без ttf_sec (нестриминговый ответ первого куска
    не видел) ttf-EMA не трогаем: подстановка total завышала first_chunk_limit,
    и зависшая голова держалась дольше. Неположительные замеры игнорируются."""
    if total_sec <= 0:
        return
    prev = _latency_ema.get(key)
    if ttf_sec is None or ttf_sec <= 0:
        obs_ttf = prev[0] if prev is not None else _DEFAULT_TTF_SEC
    else:
        obs_ttf = max(_MIN_TTF_SEC, min(_MAX_TTF_SEC, ttf_sec))
    obs_total = max(_MIN_TOTAL_SEC, min(_MAX_TOTAL_SEC, total_sec))
    if prev is None:
        _latency_ema[key] = (obs_ttf, obs_total)
    else:
        _latency_ema[key] = (
            _EMA_ALPHA * obs_ttf + (1 - _EMA_ALPHA) * prev[0],
            _EMA_ALPHA * obs_total + (1 - _EMA_ALPHA) * prev[1],
        )


def first_chunk_limit_sec(key: str, floor_sec: float) -> float:
    """min — floor от холодного старта, max — EMA_ttf × множитель от разового зависания."""
    return max(floor_sec, expected_ttf_sec(key) * _TTF_ABANDON_MULT)


def reorder_route(route: list[tuple[str, str]]) -> list[tuple[str, str]]:
    """Переставить модели по задержкам внутри подряд идущего блока одного провайдера.

    Склейка блоков по провайдеру уводила резервную Gemini-цепочку вперёд Groq
    (аудит 27.10.2026); порядок блоков — часть защиты квот, поэтому не трогаем его.
    """
    ordered: list[tuple[str, str]] = []
    run: list[tuple[str, str]] = []
    for item in route:
        if run and item[0] != run[-1][0]:
            ordered.extend(sorted(run, key=lambda i: expected_total_sec(speed_key(i[0], i[1]))))
            run = []
        run.append(item)
    if run:
        ordered.extend(sorted(run, key=lambda i: expected_total_sec(speed_key(i[0], i[1]))))
    return ordered