Потоковые ответы

Server-Sent Events в стандартном формате chat-completion-chunk, таймауты и остановка при простое.

Основы

Передайте "stream": true в теле запроса chat completion, чтобы получать Server-Sent Events в стандартном формате chunks. Шлюз прозрачно переводит поток upstream: независимо от исходного формата или Anthropic envelopes event: …, клиент видит одинаковые chunks.

Запрос

bash
curl -N -X POST https://api.toygate.store/v1/chat/completions \-H 'Authorization: Bearer gw_<your-key>' \-H 'Content-Type: application/json' \-d '{ "model": "gpt-5.5", "stream": true, "messages": [{ "role": "user", "content": "Stream me a sentence." }] }'

-N (--no-buffer) отключает буферизацию curl, поэтому chunks появляются сразу после получения.

Формат ответа

Каждое событие — строка data: с JSON, после которой идёт пустая строка. Поток завершается data: [DONE].

data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
data: {"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}
data: [DONE]

Первый chunk с ролью

Шлюз всегда отправляет первый chunk с delta.role = "assistant" и пустым content, даже если upstream этого не делает. Некоторые строгие CLI-клиенты отбрасывают сообщения, если первый delta не задаёт роль, поэтому шлюз добавляет его до передачи upstream-данных.

Usage содержит последние полученные значения

Когда upstream сообщает usage, шлюз сохраняет последние значения из стандартного потокового поля usage либо Anthropic-событий stop / message_delta. В нормальном потоке usage может прийти в заключительном chunk, но клиент не должен считать, что каждый поток содержит окончательное и гарантированно полное значение. Если upstream завершается с ошибкой до первого usage-события, во внутреннем журнале для такого потока остаётся нулевой расход токенов.

Ошибки до и после открытия потока

Обработка зависит от того, стал ли HTTP-ответ потоком SSE:

  • До начала streaming: недоступный маршрут или открытый upstream breaker возвращает обычную JSON-ошибку с HTTP 503 и error.code = "upstream_unavailable". Ошибка транспорта до получения заголовков upstream возвращает тот же код с HTTP 502.
  • После начала HTTP 200 и SSE: изменить HTTP-статус уже нельзя. Ошибка чтения, разбора, буфера или idle timeout приводит к synthetic chunk с finish_reason: "error", после которого отправляется data: [DONE]. Во внутреннем журнале запрос получает статус 502 и последние увиденные значения usage.

Таймауты

Три уровня защищают от зависшего upstream или клиентского соединения:

ТаймерПо умолчаниюПоведение
Дедлайн ответа upstream240 с (UPSTREAM_TIMEOUT_MS)Прерывает ожидание, если upstream не прислал заголовки ответа
Остановка при простое120 с (STREAM_IDLE_TIMEOUT_MS; в production: 90 с)Прерывает открытый SSE-поток, если upstream не присылает chunk заданный интервал
Таймаут простоя сокета Bun255 сОставляет клиентский сокет открытым, пока шлюз пишет поток

UPSTREAM_TIMEOUT_MS отсчитывает время до заголовков ответа upstream, а не время подключения. Именно от этого зависит, как подбирать значение: non-stream запрос не получает заголовков, пока модель не закончила генерацию, поэтому бюджет должен покрывать всю генерацию. Бюджет меньше времени генерации обрывает каждый медленный запрос и возвращает HTTP 504 с error.code = "upstream_timeout" и сообщением, называющим бюджет самого шлюза; шлюз не повторяет такой запрос и не учитывает его в circuit breaker. Допустимый диапазон — 60–240 с, ограничен таймаутом сокета выше. Non-stream запрос, которому нужно больше бюджета, обслужить нельзя в принципе — выход только streaming, о чём и сообщает текст 504.

Для streaming-запросов дедлайн снимается сразу после получения заголовков успешного ответа, и дальше за паузы между chunks отвечает STREAM_IDLE_TIMEOUT_MS — поэтому поток может законно идти минутами, а non-stream запрос к той же модели не может. Таймаут простоя срабатывает уже внутри потока: он отдаёт finish_reason: "error" и [DONE], а внутренний статус записывает как 502. Если нужно дольше 240 с — используйте streaming.

Backpressure и отмена

Если клиент отключается во время потока, шлюз отменяет чтение upstream и завершает внутреннюю запись запроса со статусом 499 и последними полученными значениями usage. Ошибочный ответ или synthetic error chunk отправить уже нельзя: клиентское соединение закрыто.

Примеры SDK

TypeScript

ts
import { OpenAI as StandardClient } from "openai";// Or import from any other standard-compatible SDKconst client = new StandardClient({apiKey: process.env.GATEWAY_KEY,baseURL: "https://api.toygate.store/v1",});

Python

python
from openai import OpenAI as StandardClient# Or import from any other standard-compatible SDKclient = StandardClient(  api_key=os.environ["GATEWAY_KEY"],  base_url="https://api.toygate.store/v1",)

Когда поток не нужен

Не используйте streaming, если полный ответ нужен до начала обработки, например для разбора JSON или вычисления одного embedding. Обычные запросы проходят полную проверку upstream-ответа (upstream_invalid_response обнаруживает некорректный JSON раньше) и не несут накладных расходов на разбор каждого chunk.