> ## Documentation Index
> Fetch the complete documentation index at: https://speshu.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Потоковая передача

> Как принимать ответ по частям и когда списываются деньги

`stream: true` переключает `/api/v1/chat/completions` на Server-Sent Events: вместо одного ответа в конце вы получаете прирастающий текст. Это единственный способ обойти потолок запроса в 300 секунд — поток идёт до 15 минут.

<Note>
  В примерах ниже `$TEXT_MODEL` — переменная окружения с идентификатором модели из `GET /api/v1/models`. Идентификаторы не зашивают в код: состав каталога меняется. Разбор — в разделе [Модели и цены](/docs/models).
</Note>

## Формат кадра

Каждый кадр — одна строка `data:` с JSON-объектом `chat.completion.chunk`. Между кадрами идут пустые строки-разделители.

```
data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1756800000,"model":"…","choices":[{"index":0,"delta":{"role":"assistant","content":"Разбираем"}}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1756800000,"model":"…","choices":[{"index":0,"delta":{"content":" задачу"}}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1756800000,"model":"…","choices":[{"index":0,"delta":{"content":" по шагам."}}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1756800000,"model":"…","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1756800000,"model":"…","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":7,"total_tokens":16,"cost":{"input_cost":0.0002,"output_cost":0.0006,"total_cost":0.0008}}}

data: [DONE]
```

<Warning>
  Сервер **никогда не пишет поле `event:`**. Все события безымянные и приходят по `data:`. Клиент или фреймворк, который разбирает поток по `event:` и без него молчит, покажет пустой ответ — при том, что HTTP-статус `200` и данные шли. Всегда читайте `data:`.
</Warning>

Структура `delta` повторяет форму сообщения: `role` приходит в первом кадре, дальше идёт `content`, при рассуждении — `reasoning` и `reasoning_content`, при вызове функций — `tool_calls`. `finish_reason` приходит пустым (`null`) во всех промежуточных кадрах и заполненным в последнем.

## Маркер `[DONE]`

Поток завершается строкой `data: [DONE]`. Это не JSON — парсить его как объект нельзя. Ниже, после `[DONE]`, сервер может дописать технические поля (`[DONE]`-строка не обязана быть самой последней), поэтому прекращайте разбор на ней, а не на закрытии соединения.

```python Python theme={null} theme={null}
if payload == "[DONE]":
    break
```

## Кадр со списанием

Непосредственно перед `[DONE]` приходит кадр, у которого `choices` — пустой массив, а в `usage` лежит разобранный расход:

```
data: {…,"choices":[],"usage":{"prompt_tokens":9,"completion_tokens":7,"total_tokens":16,"prompt_tokens_details":{"cached_read_tokens":0},"completion_tokens_details":{"reasoning_tokens":0},"cost":{"input_cost":0.0002,"output_cost":0.0006,"total_cost":0.0008}}}
```

Именно здесь списываются деньги. Этот кадр приходит **всегда**, даже если вы не задавали `stream_options.include_usage` — параметр на чат-эндпоинте ничего не отключает.

<Note>
  Списывание происходит по факту завершения генерации, а не по факту прочтения. Если кадр с `usage` не пришёл — генерация не была оплачена как завершённая; сверяйте расход по [эндпоинту баланса](/docs/api-reference/other/balance).
</Note>

## Ошибка внутри потока

Если провайдер оборвал генерацию, в середине потока приходит кадр с объектом `error`:

```
data: {"error":{"message":"upstream stream failed","type":"server_error"}}
```

После него поток закрывается. **`[DONE]` не придёт.**

```python Python theme={null} theme={null}
chunk = json.loads(payload)
if "error" in chunk:
    raise RuntimeError(chunk["error"]["message"])
```

Поэтому обрыв соединения без `[DONE]` — это сигнал, что что-то пошло не так, а не просто «поток закончился». Каждый кадр проверяйте на наличие `error`; формат ошибок разобран в [Ошибках](/docs/errors).

## Деньги и разрыв соединения

<Warning>
  Поток выполняется на контексте, отвязанном от вашего соединения. Закрыв соединение или остановив чтение, вы **не отменяете генерацию** — она доходит до конца, и вы платите за все произведённые токены, даже если не прочитали ни один. Единственный способ не платить — не отправлять запрос.
</Warning>

Практические следствия:

* Закрытие вкладки или отмена `fetch` не освобождает деньги.
* Слот параллельной генерации освобождается, а расход уже произведён.
* Не открывайте поток, если не планируете его читать: полная цена не зависит от того, сколько вы успели прочитать.
* Ставьте клиентский таймаут **больше** серверного, иначе обрыв будет на ровном месте.

## Таймауты

| Что | Потолок |
| - | - |
| Весь запрос к `/api/v1/*` | 300 секунд |
| Поток генерации текста | до 15 минут |

Переход на `stream: true` снимает 300-секундный потолок для длинных генераций. Клиент при этом должен быть готов держать соединение дольше: поставьте 900–1000 секунд.

Полный список ограничений — в разделе [Лимиты](/docs/limits).

## Примеры

### curl

Флаг `-N` отключает буферизацию вывода — без него `curl` накопит весь поток в памяти и напечатает разом в самом конце, хотя запрос отработал корректно.

```bash cURL theme={null} theme={null}
curl -N -s https://speshu.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $SPESHU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "'"$TEXT_MODEL"'",
    "stream": true,
    "messages": [{"role": "user", "content": "Напиши три абзаца про океан."}]
  }'
```

Разбор кадров на лету — печать только текста:

```bash theme={null} theme={null}
curl -N -s https://speshu.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $SPESHU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "'"$TEXT_MODEL"'",
    "stream": true,
    "messages": [{"role": "user", "content": "Напиши три абзаца про океан."}]
  }' \
  | grep --line-buffered '^data: ' \
  | sed --line-buffered 's/^data: //' \
  | while IFS= read -r line; do
      [ "$line" = "[DONE]" ] && break
      printf '%s' "$line" \
        | jq -r 'if .error then "ОШИБКА: " + .error.message
                else (.choices[0].delta.content // "")
                end'
    done
```

<Info>
  Флаги `--line-buffered` у `grep` и `sed` обязательны: без них пайп буферизует вывод блоками и поток снова выглядит как единый ответ в конце.
</Info>

### Python, синхронно

```python Python theme={null} theme={null}
import json
import os

import requests

response = requests.post(
    "https://speshu.ai/api/v1/chat/completions",
    headers={
        "Authorization": f"Bearer {os.environ['SPESHU_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "model": os.environ["TEXT_MODEL"],
        "stream": True,
        "messages": [{"role": "user", "content": "Напиши три абзаца про океан."}],
    },
    stream=True,
    timeout=(30, 900),  # таймаут чтения потока — с запасом сверх 15 минут
)
response.raise_for_status()

usage = None
for line in response.iter_lines():
    if not line or not line.startswith(b"data: "):
        continue

    payload = line[len(b"data: "):]
    if payload == b"[DONE]":
        break

    chunk = json.loads(payload)

    if "error" in chunk:
        raise RuntimeError(chunk["error"]["message"])

    if "usage" in chunk:
        usage = chunk["usage"]

    for choice in chunk.get("choices", []):
        print(choice.get("delta", {}).get("content", ""), end="", flush=True)

print()
print("списано:", usage["cost"]["total_cost"], "₽")
```

### Python, асинхронно

```python Python theme={null} theme={null}
import json
import os

import httpx


async def stream() -> str:
    text: list[str] = []
    cost = None

    async with httpx.AsyncClient(timeout=httpx.Timeout(900.0)) as client:
        async with client.stream(
            "POST",
            "https://speshu.ai/api/v1/chat/completions",
            headers={
                "Authorization": f"Bearer {os.environ['SPESHU_API_KEY']}",
                "Content-Type": "application/json",
            },
            json={
                "model": os.environ["TEXT_MODEL"],
                "stream": True,
                "messages": [{"role": "user", "content": "Напиши три абзаца про океан."}],
            },
        ) as response:
            response.raise_for_status()

            async for line in response.aiter_lines():
                if not line.startswith("data: "):
                    continue

                payload = line[len("data: "):]
                if payload == "[DONE]":
                    break

                chunk = json.loads(payload)
                if "error" in chunk:
                    raise RuntimeError(chunk["error"]["message"])

                if "usage" in chunk:
                    cost = chunk["usage"]["cost"]["total_cost"]

                for choice in chunk.get("choices", []):
                    piece = choice.get("delta", {}).get("content", "")
                    if piece:
                        text.append(piece)
                        print(piece, end="", flush=True)

    print(f"\nсписано: {cost} ₽")
    return "".join(text)


import asyncio

asyncio.run(stream())
```

### TypeScript

```typescript TypeScript theme={null} theme={null}
const response = await fetch("https://speshu.ai/api/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPESHU_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: process.env.TEXT_MODEL,
    stream: true,
    messages: [{ role: "user", content: "Напиши три абзаца про океан." }],
  }),
});

if (!response.body) throw new Error("поток пуст");

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let cost: number | null = null;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  let newline: number;
  while ((newline = buffer.indexOf("\n")) >= 0) {
    const line = buffer.slice(0, newline).trim();
    buffer = buffer.slice(newline + 1);

    if (!line.startsWith("data: ")) continue;
    const payload = line.slice(6);
    if (payload === "[DONE]") break;

    const chunk = JSON.parse(payload);
    if (chunk.error) throw new Error(chunk.error.message);
    if (chunk.usage) cost = chunk.usage.cost.total_cost;

    for (const choice of chunk.choices ?? []) {
      process.stdout.write(choice.delta?.content ?? "");
    }
  }
}

console.log(`\nсписано: ${cost} ₽`);
```

<Warning>
  В `fetch` чтение по `response.body` обязательно: если прочитать тело целиком через `response.json()`, вы потеряете эффект потока, а на длинных ответах упрётесь в таймаут. Разбирать поток нужно построчно: SSE — не JSON-массив, кадры разделены переводом строки, поэтому нужен разбор с накоплением буфера.
</Warning>

## Отмена

Отмена на стороне клиента освобождает слот параллельной генерации, но **не возвращает деньги** — генерация завершится и будет оплачена. Способ не тратить средства здесь один: не начинать запрос.

## Что дальше

<CardGroup cols={2}>
  <Card title="Запрос чату" icon="message" href="/docs/api-reference/chat/completions">
    Сводная таблица полей и формат кадра
  </Card>

  <Card title="Ошибки" icon="triangle-exclamation" href="/docs/errors">
    Ошибки внутри потока и коды повторов
  </Card>

  <Card title="Лимиты" icon="gauge-high" href="/docs/limits">
    Таймауты и параллельные потоки
  </Card>

  <Card title="Учёт средств" icon="chart-simple" href="/docs/gaidy/usage">
    Как читать списание из кадра
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.