Возможности
ВОЗМОЖНОСТИ

Стриминг ответов

Когда включать `stream: true`, как корректно собирать ответ модели и показывать прогресс пользователю.

При stream: true ответ модели приходит по частям через Server-Sent Events: вы видите текст по мере того, как он генерируется. Это даёт ощущение «живого» интерфейса: пользователь сразу видит первые слова и не ждёт полной завершённости запроса.

Технический формат SSE-стрима (chunk-структура, [DONE] маркер, заголовки) — на странице POST /v1/chat/completions: Стриминг. Этот гид — про use-cases и практику использования в клиенте.

Когда включать стриминг

Подходящие сценарии:

  • Чат-интерфейсы — пользователь видит ответ по мере появления, как в ChatGPT.
  • Длинные ответы (reasoning-модели, агенты) — без стрима пользователь смотрит 10–30 секунд в крутилку.
  • Прогресс-индикация — показать, что модель уже отвечает (даже если первые слова — это reasoning-токены).

Не нужен:

  • Однострочные ответы — классификация, экстракция данных, structured output. Накладные расходы на SSE-парсинг не оправданы.
  • Batch-сценарии — обработка тысяч запросов в parallelизме, где UI не показывается.
  • Когда нужен сразу полный JSON для парсинга — собирать stream и парсить «накопленный буфер» работает, но проще получить готовый ответ одним запросом.

Python (синхронный)

OpenAI SDK скрывает работу с SSE — итерация по объекту Stream отдаёт уже распарсенные чанки.

from openai import OpenAI

client = OpenAI(
    base_url="https://api.hubris.pw/v1",
    api_key="sk-gw-...",
)

stream = client.chat.completions.create(
    model="anthropic/claude-haiku-4.5",
    messages=[{"role": "user", "content": "Расскажи о трёх океанах планеты."}],
    stream=True,
)

for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

# В последнем чанке Hubris отдаёт usage с итоговыми токенами:
# (доступен через stream.response.usage после завершения итерации)

Итерация автоматически останавливается на [DONE]. SDK обрабатывает чанк-буфер за вас — рисковать с ручным парсингом не нужно.

Python (асинхронный)

from openai import AsyncOpenAI

client = AsyncOpenAI(
    base_url="https://api.hubris.pw/v1",
    api_key="sk-gw-...",
)

async def stream_answer(prompt: str):
    stream = await client.chat.completions.create(
        model="anthropic/claude-haiku-4.5",
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta.content if chunk.choices else None
        if delta:
            yield delta

Удобно для FastAPI / aiohttp-эндпоинтов, которые транслируют ответ дальше клиенту.

Node.js / TypeScript

import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://api.hubris.pw/v1',
  apiKey: 'sk-gw-...',
});

const stream = await client.chat.completions.create({
  model: 'anthropic/claude-haiku-4.5',
  messages: [{ role: 'user', content: 'Опиши закат.' }],
  stream: true,
});

for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content;
  if (delta) process.stdout.write(delta);
}

for await...of итерирует асинхронно — каждый цикл это уже распарсенный chunk.

Vercel AI SDK

Если вы пишете на Next.js и используете Vercel AI SDK — он работает с Hubris из коробки:

import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

const result = await streamText({
  model: openai.chat('anthropic/claude-haiku-4.5'),
  messages: [{ role: 'user', content: 'Hello' }],
});

return result.toAIStreamResponse();

Конфигурация base URL — через переменную окружения OPENAI_BASE_URL=https://api.hubris.pw/v1, либо явно в openai({ baseURL: ... }). См. гид Vercel AI SDK.

Браузер (fetch + ReadableStream)

Если SDK для вас избыточен — можно стримить голыми руками:

const resp = await fetch('https://api.hubris.pw/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk-gw-...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'anthropic/claude-haiku-4.5',
    messages: [{ role: 'user', content: 'Привет' }],
    stream: true,
  }),
});

const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });

  // Делим буфер по \n\n — каждый блок это SSE-событие
  const events = buffer.split('\n\n');
  buffer = events.pop() ?? ''; // последний может быть неполным — оставляем

  for (const event of events) {
    if (!event.startsWith('data: ')) continue;
    const payload = event.slice('data: '.length);
    if (payload === '[DONE]') return;

    const chunk = JSON.parse(payload);
    const delta = chunk.choices[0]?.delta?.content;
    if (delta) appendToUI(delta);
  }
}

Ключевые правила:

  • Буфер обязателен. TCP может разбить SSE-блок на середине — не делайте JSON.parse сразу.
  • [DONE] не парсить как JSON. Это литерал-маркер.
  • Используйте decoder.decode(value, { stream: true }) — чтобы UTF-8 multi-byte символы не ломались на границе чанков.

Cancellation (отмена запроса)

Стандартный AbortController работает:

const ac = new AbortController();

const stream = await client.chat.completions.create(
  {
    model: 'anthropic/claude-haiku-4.5',
    messages: [{ role: 'user', content: '...' }],
    stream: true,
  },
  { signal: ac.signal },
);

// Через 5 секунд отменяем
setTimeout(() => ac.abort(), 5000);

try {
  for await (const chunk of stream) {
    // ...
  }
} catch (e) {
  if (e.name === 'AbortError') {
    console.log('Запрос отменён пользователем');
  }
}

Важно: Hubris продолжает читать ответ модели до конца, даже если клиент закрыл соединение. Это защита от «бесплатных токенов». Иными словами, при отмене вы получите полное списание стоимости — токены уже сгенерированы апстримом, мы просто не доставили их вам.

Прогресс в UI: типичные паттерны

Типографический эффект (как в ChatGPT)

let buffer = '';
for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content ?? '';
  buffer += delta;
  setMessage(buffer);  // React state setter
}

Индикатор «модель думает»

Reasoning-модели могут несколько секунд молчать перед первым content-чанком (см. Reasoning токены). Покажите spinner, пока delta.content не пришёл хотя бы один раз:

let firstChunk = true;
for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content;
  if (!delta) continue;
  if (firstChunk) {
    hideSpinner();
    firstChunk = false;
  }
  appendText(delta);
}

Кнопка «Stop» с откатом UI

При отмене иногда нужно показать частичный ответ + label «прервано». Сохраняйте накопленный буфер в state и при AbortError дополняйте его пометкой — не очищайте.

Tool calls и стриминг

Если модель вызывает функцию, delta.tool_calls[] приходит частями (имя функции и аргументы могут разорваться по чанкам). Собирайте по index. Подробности — в гиде Вызов инструментов.

Биллинг

Стриминг не меняет тарификацию: списываем по тем же prompt + completion токенам, что и в обычном (не-стрим) запросе. Hubris всегда добавляет stream_options: { include_usage: true } к стрим-запросам, поэтому в последнем чанке вы видите итоговые токены.

Дисконнект клиента до завершения стрима — полное списание (см. выше). Это не баг, это анти-абьюз.

Что важно знать

  • OpenAI SDK почти всегда правильный выбор. Ручной SSE-парсинг — только если есть конкретная причина (другой стек, экстремальная оптимизация).
  • Чанк-границы непредсказуемы. Не пытайтесь парсить delta.content как JSON — это просто кусок строки.
  • Headers commit before bytes. Если стрим уже начался — мы уже отдали 200 OK и SSE-заголовки. Ошибки ПОСЛЕ старта стрима не возвращаются как HTTP 5xx — клиент видит обрыв или finish_reason: "stop" на пустом delta.

Что дальше

Обновлено:

Стриминг ответов · Hubris