feat: watchdog на первый байт стрима (first_byte_timeout) — фейловер + backoff при молчании апстрима
This commit is contained in:
@@ -322,6 +322,69 @@ upstreams:
|
|||||||
При старте выводится, что настроено:
|
При старте выводится, что настроено:
|
||||||
`[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`.
|
`[llm-proxy] backoff: upstreams=routerai-gpt4o=PT15M providers=routerai=P1M`.
|
||||||
|
|
||||||
|
### Таймаут первого байта стрима (`first_byte_timeout`)
|
||||||
|
|
||||||
|
Для `stream=true` запросов прокси следит, чтобы апстрим начал отдавать
|
||||||
|
**что-нибудь** (первый байт тела) в течение заданного окна. Если апстрим
|
||||||
|
молчит дольше — это трактуется как его сбой: слот конкурентности
|
||||||
|
освобождается, ошибка идёт в backoff-откат, и роутер **фейловерит** на
|
||||||
|
следующего свободного апстрима модели (при его наличии). Это закрывает
|
||||||
|
кейс, когда апстрим принял запрос, но «завис» и никогда не начинает
|
||||||
|
стримить.
|
||||||
|
|
||||||
|
| Поле | Тип / дефолт | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `providers[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно ожидания первого байта для всех моделей провайдера |
|
||||||
|
| `upstreams[].first_byte_timeout` | ISO-8601-длительность / отсутствует | Окно для конкретной модели (приоритет над провайдерским) |
|
||||||
|
|
||||||
|
**Дефолт** (если не задан нигде): `PT30S`.
|
||||||
|
|
||||||
|
**Приоритет — модели.** Если `upstreams[].first_byte_timeout` задан, он
|
||||||
|
перекрывает провайдерский; если нет — берётся провайдерский; если нет и там —
|
||||||
|
дефолт `PT30S`.
|
||||||
|
|
||||||
|
**Отключение:** `PT0S` на провайдере или апстриме отключает watchdog
|
||||||
|
для этого апстрима — тогда первый байт ждём без лимита (в рамках общего
|
||||||
|
`upstream: request_timeout`, см. ниже).
|
||||||
|
|
||||||
|
**Применяется только к `stream=true`.** Для `stream=false` (не-стриминговый
|
||||||
|
ответ) действует только общий лимит `UPSTREAM_REQUEST_TIMEOUT_MS` (`5m`) —
|
||||||
|
строки SSE там склеиваются в один ответ, и таймаут первого байта к нему
|
||||||
|
неприменим.
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
|
||||||
|
- watchdog висит **на первом чтении** из тела стрим-ответа; как только
|
||||||
|
пришёл хоть какой-то байт — лимит отключается, дальше стрим читается
|
||||||
|
свободно (вплоть до общего request_timeout);
|
||||||
|
- `data: [DONE]` приходит в теле — это уже «первый байт», таймаут не
|
||||||
|
сработает;
|
||||||
|
- при срабатывании — лог:
|
||||||
|
`[llm-proxy] chat model=<m> upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`
|
||||||
|
(+ `(backoff: отдых …)` если задан backoff), метрика
|
||||||
|
`llm_proxy_requests_total{result="timeout"}`;
|
||||||
|
- закрытие апстримом пустого стрима (EOF без единого байта) — **не** таймаут:
|
||||||
|
это валидный короткий ответ, он проксируется как обычно.
|
||||||
|
|
||||||
|
Значение — ISO-8601-длительность, формат как у `backoff` (`PT30S`,
|
||||||
|
`PT1M30S`, `P1D`, …).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
- id: routerai
|
||||||
|
url: "https://routerai.ru/api/v1"
|
||||||
|
first_byte_timeout: PT30S # окно для всех моделей провайдера
|
||||||
|
|
||||||
|
upstreams:
|
||||||
|
- id: routerai-gpt4o
|
||||||
|
provider: routerai
|
||||||
|
model: gpt-4o
|
||||||
|
first_byte_timeout: PT0S # у модели watchdog отключён
|
||||||
|
```
|
||||||
|
|
||||||
|
При старте выводится, что настроено:
|
||||||
|
`[llm-proxy] first_byte_timeout: upstreams=routerai-gpt4o=PT0S providers=routerai=PT30S default=PT30S`.
|
||||||
|
|
||||||
### Prometheus-метрики (`/metrics`)
|
### Prometheus-метрики (`/metrics`)
|
||||||
|
|
||||||
Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics`
|
Прокси отдаёт pull-метрики в Prometheus text-формате по `GET /metrics`
|
||||||
@@ -331,7 +394,7 @@ upstreams:
|
|||||||
|
|
||||||
| Метрика | Тип | Смысл |
|
| Метрика | Тип | Смысл |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `cancelled` — клиент отвалился посреди стрима |
|
| `llm_proxy_requests_total{model, upstream, provider, result}` | counter | chat-запросы по исходу. `result`: `ok` — успех, `4xx` — ошибка запроса, `429`/`402`/`5xx` — исход с апстрима (каждая фейловер-попытка учитывается отдельно), `net_err` — сетевая ошибка/таймаут, `timeout` — апстрим не прислал первый байт стрима за `first_byte_timeout`, `cancelled` — клиент отвалился посреди стрима |
|
||||||
| `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) |
|
| `llm_proxy_upstream_inflight{upstream, provider}` | gauge | занятые слоты апстрима прямо сейчас (конкурентность) |
|
||||||
| `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал |
|
| `llm_proxy_upstream_fail_streak{upstream, provider}` | gauge | счётчик сбоев подряд (backoff): сколько раз подряд упал |
|
||||||
| `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) |
|
| `llm_proxy_upstream_cooling_seconds{upstream, provider}` | gauge | сколько секунд апстрим ещё в backoff-откате (0 = жив) |
|
||||||
@@ -343,7 +406,7 @@ upstreams:
|
|||||||
- оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`;
|
- оборот по виртуальным моделям: `sum(rate(llm_proxy_requests_total[5m])) by (model)`;
|
||||||
- оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`;
|
- оборот по провайдерам: `sum(rate(llm_proxy_requests_total[5m])) by (provider)`;
|
||||||
- «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт;
|
- «провайдер умер»: `llm_proxy_upstream_cooling_seconds > 0` дольше N минут — алерт;
|
||||||
- доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`.
|
- доля ошибок провайдера: `sum(rate(llm_proxy_requests_total{result=~"4xx|429|402|5xx|net_err|timeout"}[10m])) by (provider) / sum(rate(llm_proxy_requests_total[10m])) by (provider)`.
|
||||||
|
|
||||||
### Пример сборки тела (многослойный `patch`)
|
### Пример сборки тела (многослойный `patch`)
|
||||||
|
|
||||||
@@ -430,9 +493,9 @@ data class ProviderConf(
|
|||||||
val reasoning_field: String? = null,
|
val reasoning_field: String? = null,
|
||||||
val reasoning_empty_ok: Boolean = false,
|
val reasoning_empty_ok: Boolean = false,
|
||||||
val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601)
|
val backoff: Duration? = null, // потолок backoff на провайдера (ISO-8601)
|
||||||
|
val first_byte_timeout: Duration? = null, // окно первого байта стрима (ISO-8601)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UpstreamConf(
|
data class UpstreamConf(
|
||||||
val id: String, // наш внутренний id апстрима
|
val id: String, // наш внутренний id апстрима
|
||||||
val provider: String, // ссылка на ProviderConf.id
|
val provider: String, // ссылка на ProviderConf.id
|
||||||
@@ -441,6 +504,7 @@ data class UpstreamConf(
|
|||||||
val patch: JsonObject? = null, // к запросам этой апстрим-модели
|
val patch: JsonObject? = null, // к запросам этой апстрим-модели
|
||||||
val think_tags: String? = null, // переопределение think-режима модели
|
val think_tags: String? = null, // переопределение think-режима модели
|
||||||
val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским)
|
val backoff: Duration? = null, // потолок backoff на модель (приоритет над провайдерским)
|
||||||
|
val first_byte_timeout: Duration? = null, // окно первого байта стрима (приоритет над провайдерским)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -605,6 +669,10 @@ fun release(u: UpstreamConf) = active.getValue(u.id).decrementAndGet()
|
|||||||
(`(backoff: отдых PT…)`); при пропуске апстрима в откате —
|
(`(backoff: отдых PT…)`); при пропуске апстрима в откате —
|
||||||
`upstream=<id> в backoff-откате (~Ns) — пропускаю`; при старте — список
|
`upstream=<id> в backoff-откате (~Ns) — пропускаю`; при старте — список
|
||||||
настроенных backoff (`backoff: upstreams=… providers=…`).
|
настроенных backoff (`backoff: upstreams=… providers=…`).
|
||||||
|
- **Таймаут первого байта стрима**: при срабатывании watchdog'а —
|
||||||
|
`upstream=<id> TIMEOUT: первый байт не пришёл за <N>ms → фейловер`;
|
||||||
|
при старте — список настроенных `first_byte_timeout`
|
||||||
|
(`first_byte_timeout: upstreams=… providers=… default=PT30S`).
|
||||||
- **Все в откате** — `503 all upstreams cooling, retry in Ns` для
|
- **Все в откате** — `503 all upstreams cooling, retry in Ns` для
|
||||||
`model=<витрина>` с заголовком `Retry-After: N`.
|
`model=<витрина>` с заголовком `Retry-After: N`.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ non-stream.
|
|||||||
1s, далее 2s, 4s, … до потолка; успех сбрасывает. Все апстримы модели в откате
|
1s, далее 2s, 4s, … до потолка; успех сбрасывает. Все апстримы модели в откате
|
||||||
— `503` с `Retry-After`.
|
— `503` с `Retry-After`.
|
||||||
|
|
||||||
|
Для `stream=true` запросов действует watchdog на первый байт ответа:
|
||||||
|
`first_byte_timeout` (ISO-8601, дефолт `PT30S`, `PT0S` отключает) — если
|
||||||
|
апстрим не прислал ни одного байта за окно, слот освобождается, ошибка идёт
|
||||||
|
в backoff и роутер фейловерит на следующего свободного апстрима. Приоритет:
|
||||||
|
`upstreams[].first_byte_timeout` → `providers[].first_byte_timeout` → `PT30S`.
|
||||||
|
|
||||||
Прокси отдаёт Prometheus-метрики по `GET /metrics` (запросы по
|
Прокси отдаёт Prometheus-метрики по `GET /metrics` (запросы по
|
||||||
`model/upstream/provider/result`, занятые слоты, счётчик и окно backoff-отката) —
|
`model/upstream/provider/result`, занятые слоты, счётчик и окно backoff-отката) —
|
||||||
подключите скрейп в Prometheus и стройте дашборды/алерты в Grafana. Список
|
подключите скрейп в Prometheus и стройте дашборды/алерты в Grafana. Список
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import io.ktor.utils.io.ByteReadChannel
|
|||||||
import io.ktor.utils.io.ByteWriteChannel
|
import io.ktor.utils.io.ByteWriteChannel
|
||||||
import io.ktor.utils.io.writeFully
|
import io.ktor.utils.io.writeFully
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.selects.onTimeout
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -103,6 +108,13 @@ fun main() {
|
|||||||
" providers=" +
|
" providers=" +
|
||||||
config.providers.filter { it.backoff != null }.map { p -> "${p.id}=${p.backoff?.toIsoString()}" }.joinToString(", ")
|
config.providers.filter { it.backoff != null }.map { p -> "${p.id}=${p.backoff?.toIsoString()}" }.joinToString(", ")
|
||||||
}
|
}
|
||||||
|
log.info {
|
||||||
|
"[llm-proxy] first_byte_timeout: upstreams=" +
|
||||||
|
config.upstreams.filter { it.first_byte_timeout != null }.map { up -> "${up.id}=${up.first_byte_timeout?.toIsoString()}" }.joinToString(", ") +
|
||||||
|
" providers=" +
|
||||||
|
config.providers.filter { it.first_byte_timeout != null }.map { p -> "${p.id}=${p.first_byte_timeout?.toIsoString()}" }.joinToString(", ") +
|
||||||
|
" default=${DEFAULT_FIRST_BYTE_TIMEOUT.toIsoString()}"
|
||||||
|
}
|
||||||
log.info {
|
log.info {
|
||||||
"[llm-proxy] server: bind host=${config.server.host} port=${config.server.port}"
|
"[llm-proxy] server: bind host=${config.server.host} port=${config.server.port}"
|
||||||
}
|
}
|
||||||
@@ -279,13 +291,14 @@ private suspend fun handleChat(
|
|||||||
val ct = resp.headers["Content-Type"] ?: "text/event-stream"
|
val ct = resp.headers["Content-Type"] ?: "text/event-stream"
|
||||||
val status = HttpStatusCode.fromValue(upstreamStatus)
|
val status = HttpStatusCode.fromValue(upstreamStatus)
|
||||||
val thinkMode = effectiveThinkTags(up, provider)
|
val thinkMode = effectiveThinkTags(up, provider)
|
||||||
|
val firstByteTimeout = effectiveFirstByteTimeout(up, provider)
|
||||||
call.respondBytesWriter(ContentType.parse(ct), status) {
|
call.respondBytesWriter(ContentType.parse(ct), status) {
|
||||||
val ch = resp.body<ByteReadChannel>()
|
val ch = resp.body<ByteReadChannel>()
|
||||||
if (thinkMode == "off") {
|
if (thinkMode == "off") {
|
||||||
// Флажок не выставлен — сырой байтовый passthrough + выравнивание конца потока.
|
// Флажок не выставлен — сырой байтовый passthrough + выравнивание конца потока.
|
||||||
streamRawWithDoneContract(ch)
|
streamRawWithDoneContract(ch, firstByteTimeout)
|
||||||
} else {
|
} else {
|
||||||
streamSseWithThinkTags(ch, thinkMode)
|
streamSseWithThinkTags(ch, thinkMode, firstByteTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metrics.record(modelName, up, "ok")
|
metrics.record(modelName, up, "ok")
|
||||||
@@ -322,6 +335,15 @@ private suspend fun handleChat(
|
|||||||
|
|
||||||
if (failover) continue
|
if (failover) continue
|
||||||
if (responded) return
|
if (responded) return
|
||||||
|
} catch (e: FirstByteTimeoutException) {
|
||||||
|
val rest = backoff.recordFailure(up)
|
||||||
|
metrics.record(modelName, up, "timeout")
|
||||||
|
log.warn {
|
||||||
|
"[llm-proxy] chat model=$modelName upstream=${up.id} TIMEOUT: первый байт не пришёл за ${e.timeoutMs}ms → фейловер" +
|
||||||
|
(rest?.let { " (backoff: отдых ${it.toIsoString()})" } ?: "")
|
||||||
|
}
|
||||||
|
failed.add(up.id)
|
||||||
|
continue
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
metrics.record(modelName, up, "cancelled")
|
metrics.record(modelName, up, "cancelled")
|
||||||
log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" }
|
log.info { "[llm-proxy] chat model=$modelName upstream=${up.id} ОТМЕНЕНО клиентом за ${start.elapsedNow().inWholeMilliseconds}ms" }
|
||||||
@@ -493,6 +515,27 @@ internal fun effectiveThinkTags(up: UpstreamConf, provider: ProviderConf?): Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эффективный first-byte-timeout для стрим-ответа: значение у апстрима, если
|
||||||
|
* задано; иначе у провайдера; иначе [DEFAULT_FIRST_BYTE_TIMEOUT]. Возвращает
|
||||||
|
* null, если явно отключено (`Duration.ZERO` — `PT0S`): тогда стрим
|
||||||
|
* ждёт первого байта без лимита (в пределах общего
|
||||||
|
* [UPSTREAM_REQUEST_TIMEOUT_MS]).
|
||||||
|
*/
|
||||||
|
internal fun effectiveFirstByteTimeout(up: UpstreamConf, provider: ProviderConf?): Duration? {
|
||||||
|
val raw = up.first_byte_timeout ?: provider?.first_byte_timeout ?: DEFAULT_FIRST_BYTE_TIMEOUT
|
||||||
|
return if (raw <= Duration.ZERO) null else raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сигнализирует, что апстрим не прислал ни одного байта тела стрим-ответа за
|
||||||
|
* [timeoutMs] мс. Обрабатывается как обычный сбой (фейловер, backoff,
|
||||||
|
* метрика `timeout`), но отделён от [CancellationException] (отмена клиентом)
|
||||||
|
* и от generic `Exception` (сетевые ошибки).
|
||||||
|
*/
|
||||||
|
class FirstByteTimeoutException(val timeoutMs: Long) :
|
||||||
|
RuntimeException("upstream did not send first byte within ${timeoutMs}ms")
|
||||||
|
|
||||||
/** Освободить слот апстрима (в finally по завершении проксирования). */
|
/** Освободить слот апстрима (в finally по завершении проксирования). */
|
||||||
internal fun release(up: UpstreamConf, active: Map<String, UpstreamCounter>) {
|
internal fun release(up: UpstreamConf, active: Map<String, UpstreamCounter>) {
|
||||||
active.getValue(up.id).release()
|
active.getValue(up.id).release()
|
||||||
@@ -827,12 +870,26 @@ internal class StreamEndDetector(private val windowSize: Int = STREAM_SCAN_WINDO
|
|||||||
* Сырой байтовый passthrough стрима (`think_tags: off`) с выравниванием контракта
|
* Сырой байтовый passthrough стрима (`think_tags: off`) с выравниванием контракта
|
||||||
* конца потока: байты уходят клиенту как есть, а если апстрим закрыл поток, не
|
* конца потока: байты уходят клиенту как есть, а если апстрим закрыл поток, не
|
||||||
* прислав `data: [DONE]`, но `finish_reason` в потоке был — дописываем маркер.
|
* прислав `data: [DONE]`, но `finish_reason` в потоке был — дописываем маркер.
|
||||||
|
* Если задан [firstByteTimeout] (non-null), первый байт тела должен прийти
|
||||||
|
* за это время, иначе бросаем [FirstByteTimeoutException]; после первого
|
||||||
|
* байта watchdog выключается (ждём ответа вплоть до общего
|
||||||
|
* [UPSTREAM_REQUEST_TIMEOUT_MS]).
|
||||||
*/
|
*/
|
||||||
internal suspend fun ByteWriteChannel.streamRawWithDoneContract(source: ByteReadChannel) {
|
internal suspend fun ByteWriteChannel.streamRawWithDoneContract(
|
||||||
|
source: ByteReadChannel,
|
||||||
|
firstByteTimeout: Duration? = null,
|
||||||
|
) {
|
||||||
val detector = StreamEndDetector()
|
val detector = StreamEndDetector()
|
||||||
val buf = ByteArray(8192)
|
val buf = ByteArray(8192)
|
||||||
|
val timeoutMs = firstByteTimeout?.inWholeMilliseconds ?: 0L
|
||||||
|
var isFirstRead = timeoutMs > 0L
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = source.readAvailable(buf)
|
val n = if (isFirstRead) {
|
||||||
|
isFirstRead = false
|
||||||
|
readAvailableWithTimeout(source, buf, timeoutMs)
|
||||||
|
} else {
|
||||||
|
source.readAvailable(buf)
|
||||||
|
}
|
||||||
if (n == -1) break
|
if (n == -1) break
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
writeFully(buf, 0, n)
|
writeFully(buf, 0, n)
|
||||||
@@ -853,14 +910,30 @@ internal suspend fun ByteWriteChannel.streamRawWithDoneContract(source: ByteRead
|
|||||||
* записывается как `data: <json>\n\n` (событие-граница SSE), а при ошибке парса —
|
* записывается как `data: <json>\n\n` (событие-граница SSE), а при ошибке парса —
|
||||||
* исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в
|
* исходная строка. Каждую строку сразу `flush()`, чтобы стрим не «залипал» в
|
||||||
* буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком.
|
* буфере. В конце потока накопленные хвосты сплиттеров сбрасываются финиш-чанком.
|
||||||
|
* Если задан [firstByteTimeout] (non-null), первая строка должна прийти за это
|
||||||
|
* время, иначе [FirstByteTimeoutException] (watchdog только на первой строке).
|
||||||
*/
|
*/
|
||||||
internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadChannel, thinkMode: String) {
|
internal suspend fun ByteWriteChannel.streamSseWithThinkTags(
|
||||||
|
source: ByteReadChannel,
|
||||||
|
thinkMode: String,
|
||||||
|
firstByteTimeout: Duration? = null,
|
||||||
|
) {
|
||||||
val splitters = mutableMapOf<Int, ThinkTagSplitter>()
|
val splitters = mutableMapOf<Int, ThinkTagSplitter>()
|
||||||
val addReasoning = thinkMode == "split"
|
val addReasoning = thinkMode == "split"
|
||||||
var sawDone = false
|
var sawDone = false
|
||||||
var sawFinishReason = false
|
var sawFinishReason = false
|
||||||
|
val timeoutMs = firstByteTimeout?.inWholeMilliseconds ?: 0L
|
||||||
|
var isFirstRead = timeoutMs > 0L
|
||||||
|
|
||||||
|
suspend fun readNext(): String? = if (isFirstRead) {
|
||||||
|
isFirstRead = false
|
||||||
|
readLineWithTimeout(source, timeoutMs)
|
||||||
|
} else {
|
||||||
|
source.readLine(LineEnding.Lenient)
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val line = source.readLine(LineEnding.Lenient) ?: break
|
val line = readNext() ?: break
|
||||||
when {
|
when {
|
||||||
line.startsWith("data:") -> {
|
line.startsWith("data:") -> {
|
||||||
val payload = line.removePrefix("data:").trim()
|
val payload = line.removePrefix("data:").trim()
|
||||||
@@ -913,6 +986,49 @@ internal suspend fun ByteWriteChannel.streamSseWithThinkTags(source: ByteReadCha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать из [source] в [buf] с жёстким лимитом [timeoutMs]: если за это
|
||||||
|
* время ни одного байта не пришло, бросить [FirstByteTimeoutException].
|
||||||
|
* `select` отменяет «проигравшую» ветку; при таймауте явно отменяем и
|
||||||
|
* дожидаемся чтение (`cancelAndJoin`), чтобы соединение с апстримом
|
||||||
|
* корректно закрылось. Сентинел [Int.MIN_VALUE] невозможен как результат
|
||||||
|
* [ByteReadChannel.readAvailable] (это `-1` для EOF или `>= 0` для байт).
|
||||||
|
*/
|
||||||
|
internal suspend fun readAvailableWithTimeout(source: ByteReadChannel, buf: ByteArray, timeoutMs: Long): Int =
|
||||||
|
coroutineScope {
|
||||||
|
val readJob = async { source.readAvailable(buf) }
|
||||||
|
val result = select<Int> {
|
||||||
|
readJob.onAwait { it }
|
||||||
|
onTimeout(timeoutMs) { Int.MIN_VALUE }
|
||||||
|
}
|
||||||
|
if (result == Int.MIN_VALUE) {
|
||||||
|
readJob.cancelAndJoin()
|
||||||
|
throw FirstByteTimeoutException(timeoutMs)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать строку из [source] с лимитом [timeoutMs]: если за это время ни
|
||||||
|
* одной строки/байта не пришло, бросить [FirstByteTimeoutException].
|
||||||
|
* [ByteReadChannel.readLine] возвращает `null` и при EOF, и (через `onTimeout`)
|
||||||
|
* при таймауте; различаем по состоянию [readJob]: если ветка чтения не
|
||||||
|
* завершилась к моменту возврата из `select` — таймаут (select отменил её).
|
||||||
|
*/
|
||||||
|
internal suspend fun readLineWithTimeout(source: ByteReadChannel, timeoutMs: Long): String? =
|
||||||
|
coroutineScope {
|
||||||
|
val readJob = async { source.readLine(LineEnding.Lenient) }
|
||||||
|
val result = select<String?> {
|
||||||
|
readJob.onAwait { it }
|
||||||
|
onTimeout(timeoutMs) { null }
|
||||||
|
}
|
||||||
|
if (result == null && !readJob.isCompleted) {
|
||||||
|
readJob.cancelAndJoin()
|
||||||
|
throw FirstByteTimeoutException(timeoutMs)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пересобрать SSE-чанк: по каждому choice (ключ `index`, дефолт 0) взять
|
* Пересобрать SSE-чанк: по каждому choice (ключ `index`, дефолт 0) взять
|
||||||
* `delta.content` (только если это JSON-строка; массив частей не трогаем) и
|
* `delta.content` (только если это JSON-строка; массив частей не трогаем) и
|
||||||
@@ -966,6 +1082,7 @@ data class ProviderConf(
|
|||||||
val reasoning_field: String? = null,
|
val reasoning_field: String? = null,
|
||||||
val reasoning_empty_ok: Boolean = false,
|
val reasoning_empty_ok: Boolean = false,
|
||||||
val backoff: Duration? = null,
|
val backoff: Duration? = null,
|
||||||
|
val first_byte_timeout: Duration? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class UpstreamConf(
|
data class UpstreamConf(
|
||||||
@@ -976,6 +1093,7 @@ data class UpstreamConf(
|
|||||||
val patch: JsonObject? = null,
|
val patch: JsonObject? = null,
|
||||||
val think_tags: String? = null,
|
val think_tags: String? = null,
|
||||||
val backoff: Duration? = null,
|
val backoff: Duration? = null,
|
||||||
|
val first_byte_timeout: Duration? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ModelConf(
|
data class ModelConf(
|
||||||
@@ -1034,6 +1152,7 @@ internal fun parseConfig(root: YamlElement): Config {
|
|||||||
reasoning_field = m.strOrNull("reasoning_field"),
|
reasoning_field = m.strOrNull("reasoning_field"),
|
||||||
reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false,
|
reasoning_empty_ok = m.strOrNull("reasoning_empty_ok")?.toBooleanStrictOrNull() ?: false,
|
||||||
backoff = m.durationOrNull("backoff"),
|
backoff = m.durationOrNull("backoff"),
|
||||||
|
first_byte_timeout = m.durationOrNull("first_byte_timeout"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1166,7 @@ internal fun parseConfig(root: YamlElement): Config {
|
|||||||
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
|
patch = m.yamlMapOrNull("patch")?.let { yamlToJson(it) as JsonObject },
|
||||||
think_tags = m.strOrNull("think_tags"),
|
think_tags = m.strOrNull("think_tags"),
|
||||||
backoff = m.durationOrNull("backoff"),
|
backoff = m.durationOrNull("backoff"),
|
||||||
|
first_byte_timeout = m.durationOrNull("first_byte_timeout"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
*
|
*
|
||||||
* Метрики (всё в памяти, сброс при рестарте):
|
* Метрики (всё в памяти, сброс при рестарте):
|
||||||
* - `llm_proxy_requests_total{model,upstream,provider,result}` — счётчик
|
* - `llm_proxy_requests_total{model,upstream,provider,result}` — счётчик
|
||||||
* chat-запросов; `result`: `ok | 4xx | 429 | 402 | 5xx | net_err | cancelled`;
|
* chat-запросов; `result`: `ok | 4xx | 429 | 402 | 5xx | net_err | timeout | cancelled`;
|
||||||
* - `llm_proxy_upstream_inflight{upstream,provider}` — сейчас в работе (слоты);
|
* - `llm_proxy_upstream_inflight{upstream,provider}` — сейчас в работе (слоты);
|
||||||
* - `llm_proxy_upstream_fail_streak{upstream,provider}` — текущая серия сбоев
|
* - `llm_proxy_upstream_fail_streak{upstream,provider}` — текущая серия сбоев
|
||||||
* (счётчик backoff);
|
* (счётчик backoff);
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
package pw.binom.llmproxy
|
package pw.binom.llmproxy
|
||||||
|
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/** Предел времени на весь запрос к апстриму, мс: от отправки до приёма всего ответа (включая стриминг). */
|
/** Предел времени на весь запрос к апстриму, мс: от отправки до приёма всего ответа (включая стриминг). */
|
||||||
const val UPSTREAM_REQUEST_TIMEOUT_MS: Long = 5 * 60_000L
|
const val UPSTREAM_REQUEST_TIMEOUT_MS: Long = 5 * 60_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default «watchdog» на первый байт тела стрим-ответа: если апстрим не прислал
|
||||||
|
* ни одного байта за это время, прокси считает это сбоем апстрима и
|
||||||
|
* фейловерит (освобождает слот конкурентности, считает ошибку в backoff и
|
||||||
|
* идёт к следующему апстриму модели). Применяется ТОЛЬКО к stream=true
|
||||||
|
* запросам; для non-stream работает общий [UPSTREAM_REQUEST_TIMEOUT_MS].
|
||||||
|
* Отключается явным `first_byte_timeout: PT0S` на апстриме или провайдере.
|
||||||
|
*/
|
||||||
|
val DEFAULT_FIRST_BYTE_TIMEOUT: Duration = 30.seconds
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import io.ktor.http.headersOf
|
import io.ktor.http.headersOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -586,4 +587,32 @@ class ConfigLogicTest {
|
|||||||
assertEquals(null, cfg.providers[0].reasoning_field)
|
assertEquals(null, cfg.providers[0].reasoning_field)
|
||||||
assertEquals(false, cfg.providers[0].reasoning_empty_ok)
|
assertEquals(false, cfg.providers[0].reasoning_empty_ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseConfigReadsFirstByteTimeoutOnProviderAndUpstream() {
|
||||||
|
val yaml = """
|
||||||
|
providers:
|
||||||
|
- id: p1
|
||||||
|
url: "https://x.ru/api/v1"
|
||||||
|
first_byte_timeout: PT45S
|
||||||
|
- id: p2
|
||||||
|
url: "https://y.ru/api/v1"
|
||||||
|
upstreams:
|
||||||
|
- id: u1
|
||||||
|
provider: p1
|
||||||
|
model: real-1
|
||||||
|
first_byte_timeout: PT0S
|
||||||
|
- id: u2
|
||||||
|
provider: p2
|
||||||
|
model: real-2
|
||||||
|
models:
|
||||||
|
- name: m1
|
||||||
|
upstreams: [u1, u2]
|
||||||
|
""".trimIndent()
|
||||||
|
val cfg = parseConfig(Yaml.decodeYamlFromString(yaml))
|
||||||
|
assertEquals(45.seconds, cfg.providers[0].first_byte_timeout)
|
||||||
|
assertEquals(null, cfg.providers[1].first_byte_timeout)
|
||||||
|
assertEquals(0.seconds, cfg.upstreams[0].first_byte_timeout)
|
||||||
|
assertEquals(null, cfg.upstreams[1].first_byte_timeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package pw.binom.llmproxy
|
||||||
|
|
||||||
|
import io.ktor.utils.io.ByteChannel
|
||||||
|
import io.ktor.utils.io.close
|
||||||
|
import io.ktor.utils.io.readAvailable
|
||||||
|
import io.ktor.utils.io.writeFully
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты watchdog'а на первый байт тела стрим-ответа.
|
||||||
|
*
|
||||||
|
* Сценарии:
|
||||||
|
* - Апстрим не прислал ничего за N мс → [FirstByteTimeoutException] на первой
|
||||||
|
* попытке чтения (после первой попытки watchdog отключается).
|
||||||
|
* - Апстрим закрыл канал до таймаута, не прислав данных → возврат EOF (-1 /
|
||||||
|
* null), НЕ [FirstByteTimeoutException].
|
||||||
|
* - Апстрим прислал первый байт вовремя → читаем его, watchdog отключён.
|
||||||
|
* - Когда таймаут не задан (`null`) → читаем без лимита (как и было).
|
||||||
|
*/
|
||||||
|
class FirstByteTimeoutTest {
|
||||||
|
|
||||||
|
/** Окно для watchdog'а в тестах: виртуальное время `runTest` его мгновенно прокручивает. */
|
||||||
|
private val watchWindow = 100.milliseconds
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readAvailableTimesOutWhenNoBytesAndNotClosed() = runTest {
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val buf = ByteArray(64)
|
||||||
|
val done = CompletableDeferred<Throwable?>()
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
|
||||||
|
done.complete(AssertionError("expected FirstByteTimeoutException"))
|
||||||
|
} catch (e: FirstByteTimeoutException) {
|
||||||
|
done.complete(null)
|
||||||
|
assertEquals(watchWindow.inWholeMilliseconds, e.timeoutMs)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
done.complete(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
|
||||||
|
runCurrent()
|
||||||
|
val err = done.await()
|
||||||
|
if (err != null) throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readAvailableReturnsEofImmediatelyWhenChannelClosedEmpty() = runTest {
|
||||||
|
// Канал сразу закрыт без данных: -1 (EOF), никакого таймаута.
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
src.close(null)
|
||||||
|
val buf = ByteArray(64)
|
||||||
|
|
||||||
|
val n = readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
|
||||||
|
assertEquals(-1, n, "закрытый пустой канал → EOF, не таймаут")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readAvailableReturnsBytesWhenDataArrivesInTime() = runTest {
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val payload = "hello".encodeToByteArray()
|
||||||
|
src.writeFully(payload)
|
||||||
|
val buf = ByteArray(64)
|
||||||
|
|
||||||
|
val n = readAvailableWithTimeout(src, buf, watchWindow.inWholeMilliseconds)
|
||||||
|
assertEquals(payload.size, n)
|
||||||
|
assertEquals(payload.decodeToString(), buf.decodeToString(0, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readLineTimesOutWhenNoLinesAndNotClosed() = runTest {
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val done = CompletableDeferred<Throwable?>()
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
readLineWithTimeout(src, watchWindow.inWholeMilliseconds)
|
||||||
|
done.complete(AssertionError("expected FirstByteTimeoutException"))
|
||||||
|
} catch (e: FirstByteTimeoutException) {
|
||||||
|
done.complete(null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
done.complete(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
|
||||||
|
runCurrent()
|
||||||
|
val err = done.await()
|
||||||
|
if (err != null) throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readLineReturnsNullWhenChannelClosedEmpty() = runTest {
|
||||||
|
// Закрытый пустой канал → null (EOF), не таймаут. Это критично: иначе бы
|
||||||
|
// мы фейловерили легитимные короткие ответы, где апстрим закрыл стрим
|
||||||
|
// до первой строки.
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
src.close(null)
|
||||||
|
|
||||||
|
val line = readLineWithTimeout(src, watchWindow.inWholeMilliseconds)
|
||||||
|
assertEquals(null, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rawStreamTimesOutWhenUpstreamSilent() = runTest {
|
||||||
|
// Сырой passthrough: апстрим молчит вечно → на первой попытке чтения
|
||||||
|
// вылетает FirstByteTimeoutException, в out ничего не пишется.
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val out = ByteChannel(autoFlush = true)
|
||||||
|
val done = CompletableDeferred<Throwable?>()
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
out.streamRawWithDoneContract(src, watchWindow)
|
||||||
|
done.complete(AssertionError("expected FirstByteTimeoutException"))
|
||||||
|
} catch (e: FirstByteTimeoutException) {
|
||||||
|
done.complete(null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
done.complete(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
|
||||||
|
runCurrent()
|
||||||
|
val err = done.await()
|
||||||
|
if (err != null) throw err
|
||||||
|
|
||||||
|
// Подтверждаем, что в out ничего не утекло.
|
||||||
|
out.close(null)
|
||||||
|
val buf = ByteArray(64)
|
||||||
|
val n = out.readAvailable(buf)
|
||||||
|
assertEquals(-1, n, "до таймаута клиенту ничего не должно уйти")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkStreamTimesOutWhenUpstreamSilent() = runTest {
|
||||||
|
// SSE-вариант: первая строка не приходит → FirstByteTimeoutException,
|
||||||
|
// в out ничего не пишется.
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val out = ByteChannel(autoFlush = true)
|
||||||
|
val done = CompletableDeferred<Throwable?>()
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
out.streamSseWithThinkTags(src, "split", watchWindow)
|
||||||
|
done.complete(AssertionError("expected FirstByteTimeoutException"))
|
||||||
|
} catch (e: FirstByteTimeoutException) {
|
||||||
|
done.complete(null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
done.complete(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advanceTimeBy(watchWindow.inWholeMilliseconds + 50)
|
||||||
|
runCurrent()
|
||||||
|
val err = done.await()
|
||||||
|
if (err != null) throw err
|
||||||
|
|
||||||
|
out.close(null)
|
||||||
|
val buf = ByteArray(64)
|
||||||
|
val n = out.readAvailable(buf)
|
||||||
|
assertEquals(-1, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkStreamFirstLineInTimeThenReadsUnlimited() = runTest {
|
||||||
|
// Первый байт/строка пришли вовремя → watchdog выключен, остальной
|
||||||
|
// поток читается обычным порядком. Проверяем, что НЕ бросило
|
||||||
|
// FirstByteTimeoutException и данные дошли до out.
|
||||||
|
val src = ByteChannel(autoFlush = true)
|
||||||
|
val out = ByteChannel(autoFlush = true)
|
||||||
|
val payload = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n"
|
||||||
|
src.writeFully(payload.encodeToByteArray())
|
||||||
|
src.close(null)
|
||||||
|
|
||||||
|
out.streamSseWithThinkTags(src, "split", watchWindow)
|
||||||
|
|
||||||
|
out.close(null)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
val buf = ByteArray(256)
|
||||||
|
while (true) {
|
||||||
|
val n = out.readAvailable(buf)
|
||||||
|
if (n == -1) break
|
||||||
|
if (n > 0) sb.append(buf.decodeToString(0, n))
|
||||||
|
}
|
||||||
|
assertTrue(sb.toString().contains("data:"), "данные дошли до out: $sb")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveFirstByteTimeoutPrefersUpstreamThenProviderThenDefault() {
|
||||||
|
// upstream > provider > default
|
||||||
|
val provider = ProviderConf("p", "https://x", "", null, null, first_byte_timeout = 20.milliseconds)
|
||||||
|
val upstream = UpstreamConf("u", "p", "m", null, null, null, null, first_byte_timeout = 5.milliseconds)
|
||||||
|
val upstreamNoOwn = UpstreamConf("u", "p", "m")
|
||||||
|
|
||||||
|
assertEquals(5.milliseconds, effectiveFirstByteTimeout(upstream, provider))
|
||||||
|
assertEquals(20.milliseconds, effectiveFirstByteTimeout(upstreamNoOwn, provider))
|
||||||
|
assertEquals(DEFAULT_FIRST_BYTE_TIMEOUT, effectiveFirstByteTimeout(upstreamNoOwn, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveFirstByteTimeoutZeroDisablesWatchdog() {
|
||||||
|
// 0s / PT0S → null: passthrough без watchdog'а.
|
||||||
|
val up = UpstreamConf("u", "p", "m", null, null, null, null, first_byte_timeout = 0.milliseconds)
|
||||||
|
assertEquals(null, effectiveFirstByteTimeout(up, null))
|
||||||
|
|
||||||
|
val providerZero = ProviderConf("p", "https://x", "", null, null, first_byte_timeout = 0.milliseconds)
|
||||||
|
val upNoOwn = UpstreamConf("u", "p", "m")
|
||||||
|
assertEquals(null, effectiveFirstByteTimeout(upNoOwn, providerZero))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user