NormaHub knowledge base

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

Streaming через Responses API: формат SSE, чтение потока байтов, обрывы, таймауты и финальный учёт стоимости.

1. Запрос

Streaming доступен через Responses API у моделей, которые поддерживают этот маршрут. Минимальный запрос — curl ниже: обратите внимание на stream: true и ограничение max_output_tokens, без которого поток может идти неожиданно долго и дорого.

curl https://api.normahub.cc/v1/responses \
  -H "Authorization: Bearer $NORMAHUB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "MODEL_ID",
    "input": "Объясни результат по шагам",
    "max_output_tokens": 512,
    "stream": true
  }'

Ответ передаётся как text/event-stream. Читайте события последовательно и извлекайте содержимое из строк data:.

2. Чтение потока байтов

Главная ошибка новичков — считать сетевой chunk событием. Один chunk может содержать несколько SSE-событий или половину строки: клиент обязан накапливать буфер, декодировать UTF-8 потоково и резать события по пустой строке, сохраняя хвост до следующего чтения. Data-события разбирайте отдельно от служебных маркеров, завершение — отдельным состоянием.

const response = await fetch("https://api.normahub.cc/v1/responses", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.NORMAHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ model: "MODEL_ID", input: "Привет", stream: true }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let done = false;

while (!done) {
  const { value, done: finished } = await reader.read();
  done = finished;
  buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
  const parts = buffer.split("

");
  buffer = parts.pop() ?? "";
  for (const event of parts) {
    if (!event.startsWith("data:")) continue; // служебные маркеры отдельно
    const payload = event.slice(5).trim();
    if (payload === "[DONE]") { done = true; break; }
    process.stdout.write(payload);
  }
}
reader.releaseLock();

При отмене пользователем закрывайте reader явно и фиксируйте состояние «отменён пользователем», а не «ошибка»: это разные ветки аналитики и биллинга.

3. Обрыв — не завершение

Не считайте разрыв TCP успешным завершением. Дождитесь финального события потока, закройте reader и сохраните X-Request-Id. Gateway завершает запись usage и стоимости при закрытии streaming-операции, поэтому итог запроса существует даже если пользователь закрыл вкладку на середине.

Минимальный набор тестов перед продом: короткий ответ, длинный ответ, медленный ответ, отмена пользователем, обрыв сети, ошибка после первого события. Для каждого зафиксируйте, что видит пользователь и какой итог сохраняется в usage. Повтор после частично полученного ответа — только с явной стратегией защиты от дублирования.

4. Ограничения и таймауты

В текущей версии NormaHub streaming не включён для /v1/chat/completions и /v1/messages. Для них отправляйте stream: false либо используйте Responses API.

Задавайте отдельные таймауты подключения и чтения. После частично полученного ответа не выполняйте автоматический повтор без явной стратегии защиты от дублирования. Ошибки, пришедшие событием после начала потока, разбирайте по гайду по ошибкам, а цену потока — по гайду по стоимости. Архитектура долгих потоковых задач — в статье про long-running агентов.

Частые вопросы

На каких маршрутах работает streaming?

Гарантированно — через Responses API у моделей, заявляющих этот маршрут. Для /v1/chat/completions и /v1/messages поток в текущей версии не включён: отправляйте stream: false либо переходите на Responses API.

Обрыв посередине — запрос считается выполненным?

Неизвестно без проверки: сервер мог уже завершить операцию и списать usage. Сохраняйте полученный префикс, фиксируйте X-Request-Id и не повторяйте неидемпотентное действие вслепую.

Как отличить конец ответа от обрыва?

Только по финальному событию потока. Закрытие TCP-соединения, пустой чанк и таймаут чтения — это обрывы, а не завершения. Разные состояния — разная логика UI и журнала.

Когда списывается стоимость потокового запроса?

Gateway финализирует usage и стоимость при закрытии streaming-операции. Итог смотрите в истории личного кабинета, а не по длине показанного пользователю текста.

Нужен ли отдельный таймаут для потока?

Да, два: на установку соединения и на чтение (пауза между событиями). Один общий timeout либо рвёт длинные ответы, либо вешает зависшие соединения.