Стриминг
SSE в трёх диалектах шлюза — как включить поток, какие приходят события, чем кончается целый ответ и почему после первого байта вызов уже не переносят.
Быстро
Добавьте "stream": true к тому же вызову — и ответ придёт как text/event-stream. На Chat Completions попросите ещё и расход: stream_options.include_usage.
curl -N https://api.kumorouter.com/v1/chat/completions \
-H "Authorization: Bearer $KUMO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<model>",
"max_tokens": 128,
"stream": true,
"stream_options": { "include_usage": true },
"messages": [{ "role": "user", "content": "Напиши хайку про задержку." }]
}'Поток без завершающего кадра — это неудавшийся вызов
Отказ, решённый до первого события, приходит обычной JSON-ошибкой со статусом — тем же конвертом, что и у неразбитого на события вызова, и в потоке при этом нет ничего. Сбой после первого события таким быть уже не может: строка статуса ушла раньше, и её не переписать.
Поэтому каждый диалект называет свой завершающий кадр, и целый ответ виден именно по нему.
| Диалект | Завершающий кадр | Если его не пришло |
|---|---|---|
| Chat Completions | [DONE] | Поток просто кончился. Ответ усечён. |
| Anthropic Messages | message_stop | Пришло событие error этого протокола, и поток кончился без message_stop. |
| Responses | response.completed | Пришло response.failed с конвертом ошибки вместо готового тела. |
Читайте именно это отсутствие. Клиент, который считает «соединение закрылось» синонимом «ответ дописан», отдаст дальше усечённый текст, и в полученных им байтах ничто не скажет обратного. Поток, кончившийся до завершающего кадра, — неудавшийся вызов, а не короткий.
Практическое правило: держите флаг «завершающий кадр видели» и после выхода из цикла проверяйте его первым, до того как отдать текст дальше.
Что приходит в потоке
Chat Completions
События chat.completion.chunk в порядке прихода. У каждого id, model и object те же, что были бы у унарного ответа, а содержимое лежит в choices[0].delta: role — на первом чанке, дальше content, refusal или tool_calls кусками. finish_reason заявляется один раз, на чанке, который закрывает ответ, и до тех пор он null.
Если запрошен stream_options.include_usage, перед [DONE] придёт чанк расхода: событие с пустым choices, несущее только usage. На то, из чего платформа считает вызов, этот член не влияет — расход собирается всегда, и он решает лишь, покажет ли его ваш собственный поток.
data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Задержка"},"finish_reason":null}]}
data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-8f2b7e10c9","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":18,"total_tokens":30}}
data: [DONE]Anthropic Messages
Шесть именованных событий по порядку: message_start — открывающий конверт с личностью ответа и входной половиной расхода; content_block_start, content_block_delta, content_block_stop — на каждый блок; message_delta — закрывающие факты, stop_reason и расход целиком; message_stop. Кроме них приходят ping и error.
Дельта блока бывает двух видов: text_delta несёт кусок текста, input_json_delta — кусок аргументов вызова инструмента как частичный JSON в partial_json.
Responses
Собственные именованные события: response.created, response.output_item.added, response.output_text.delta, response.refusal.delta, response.function_call_arguments.delta, response.output_item.done и ровно одно завершающее — response.completed или response.failed. Имя события повторяется в поле event: SSE-кадра, так что ветвиться можно на любом из двух.
Расход приходит на response.completed. response.failed несёт конверт ошибки этого протокола в члене error.
Поток на диалекте Messages
import os
from anthropic import Anthropic
client = Anthropic(
base_url="https://api.kumorouter.com",
api_key=os.environ["KUMO_API_KEY"],
)
with client.messages.stream(
model="<model>",
max_tokens=256,
messages=[{"role": "user", "content": "Напиши хайку про задержку."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)Первый байт решает всё
До модели, как правило, есть больше одного канала, и отказ канала до того, как до вас дошла хоть часть ответа, переносит вызов на следующий канал той же модели.
После первого байта переноса нет и быть не может: вам нельзя выдать второй ответ посреди того, который вы уже читаете. Отсюда и берётся всё поведение потока — обрыв вместо ошибки, отсутствие кадра вместо конверта.
Отмена
Закрыв соединение, вы отменяете вызов: платформа видит отключение и записывает его как ваш отказ ждать. Отменённый вызов на другой канал не переносится — вы его больше не ждёте.
Расход, который поставщик успел подтвердить, всё равно рассчитывается: гонка вашего отключения с приходом результата не делает работу бесплатной. Там, где подтверждать нечего, вызов освобождает то, что держал.
Где стриминга нет
- У эмбеддингов стриминга нет: эта поверхность отвечает векторами целиком.
- У генерации изображений члена
streamнет вовсе. - Подсчёт токенов к провайдеру не обращается и потому потока не даёт.
Стриминг — это доказанная возможность точной пары модели и канала, а не свойство протокола. Запрос со stream: true не поедет к паре, которая стриминг не доказала: он отвергается до обращения наверх. Что доказано у конкретной модели, видно на её карточке в каталоге.