Skip to main content

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

Как включить streaming в POST /v1/chat/completions и разбирать SSE-чанки в GonkaGate.

Добавьте stream: true в OpenAI-совместимый POST /v1/chat/completions, чтобы получать Server-Sent Events (SSE) из GonkaGate по мере генерации токенов. Читайте каждое data:-событие инкрементально, игнорируйте keep-alive-комментарии и считайте ответ завершённым только после финального чанка с usage и [DONE].

Сначала добейтесь рабочего непотокового запроса. Streaming нужен для чат-интерфейсов, длинных ответов и операторских сценариев, где важен частичный вывод. Если нужен только финальный результат, JSON-ответ проще выкатить и отладить.

Минимальный рабочий пример

Минимальный рабочий пример
export GONKAGATE_API_KEY="gp-your-api-key"

curl -N https://api.gonkagate.com/v1/chat/completions \
  -H "Authorization: Bearer $GONKAGATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen/qwen3-235b-a22b-instruct-2507-fp8",
    "messages": [
      { "role": "user", "content": "Объясни SSE одним предложением." }
    ],
    "stream": true
  }'

Используйте -N, чтобы curl печатал поток по мере прихода чанков. Замените ID модели на модель, доступную вашему аккаунту. Для запроса также нужен валидный API-ключ и достаточный предоплаченный USD-баланс.

Как устроен SSE-поток

Успешный потоковый ответ приходит как последовательность SSE-событий, а не как один JSON-документ:

Как устроен SSE-поток
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1735500000,"model":"qwen/qwen3-32b-fp8","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1735500000,"model":"qwen/qwen3-32b-fp8","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1735500000,"model":"qwen/qwen3-32b-fp8","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1735500000,"model":"qwen/qwen3-32b-fp8","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"base_cost_usd":0.000075,"platform_fee_usd":0.0000075,"total_cost_usd":0.0000825}}

data: [DONE]

Читать поток стоит в таком порядке:

  • Первый чанк может только объявить роль assistant и ещё не содержать текст.
  • Текст обычно приходит в следующих чанках через choices[0].delta.content.
  • Последний чанк с текстом может содержать finish_reason.
  • Финальные usage и cost приходят в отдельном чанке только с usage и choices: [].
  • Игнорируйте keep-alive-комментарии вроде : keep-alive и другие не-data: строки.
  • Поток завершён только после [DONE].

Минимальный пример парсера

TypeScript (fetch)
const response = await fetch("https://api.gonkagate.com/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.GONKAGATE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "qwen/qwen3-235b-a22b-instruct-2507-fp8",
    messages: [{ role: "user", content: "Объясни SSE одним предложением." }],
    stream: true,
  }),
});

if (!response.ok) {
  throw new Error(`Streaming request failed with ${response.status}`);
}

const reader = response.body?.getReader();
if (!reader) {
  throw new Error("Response body is not readable");
}

const decoder = new TextDecoder();
let buffer = "";
let streamDone = false;
let finalUsage: Record<string, unknown> | null = null;

while (!streamDone) {
  const { done, value } = await reader.read();

  if (done) {
    throw new Error("Stream ended before [DONE]");
  }

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

  while (true) {
    const newlineIndex = buffer.indexOf("\n");
    if (newlineIndex === -1) {
      break;
    }

    const line = buffer.slice(0, newlineIndex).trim();
    buffer = buffer.slice(newlineIndex + 1);

    if (!line.startsWith("data:")) {
      continue;
    }

    const payload = line.slice(5).trim();

    if (payload === "[DONE]") {
      streamDone = true;
      break;
    }

    const chunk = JSON.parse(payload) as {
      error?: { message?: string; type?: string; code?: string };
      choices?: Array<{ delta?: { content?: string } }>;
      usage?: Record<string, unknown>;
    };

    if (chunk.error) {
      throw new Error(
        `Streaming error (${chunk.error.code ?? chunk.error.type ?? "unknown"}): ${chunk.error.message ?? "Unknown error"}`
      );
    }

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

    if (chunk.usage) {
      finalUsage = chunk.usage;
    }
  }
}

console.log("\nFinal usage:", finalUsage);

Этот паттерн оставляет парсер простым: игнорируйте не-data: строки, разбирайте каждый полный SSE payload, выводите delta.content по мере прихода, останавливайтесь на SSE-событии ошибки, считайте преждевременное закрытие потока ошибкой и считайте usage или cost финальными только после последнего чанка с usage.

Частые ошибки и сбои

  • Клиент, который ждёт один полный JSON-ответ, будет выглядеть «зависшим», даже если стрим здоровый. Нужен клиент с поддержкой SSE или инкрементальное чтение ответа.
  • Не считайте ответ завершённым по первому токену. Завершение наступает только после [DONE].
  • Не финализируйте стоимость или usage до последнего чанка с usage.
  • Если ошибка случилась после старта стрима, не ждите обычный HTTP status 4xx/5xx. Обрабатывайте data: {"error": ...} как терминальное SSE-событие.
  • Не стройте парсер вокруг GonkaGate-specific prelude events для public privacy-sanitization. Держите потоковый парсер сфокусированным на обычных SSE data: payload'ах, keep-alive-комментариях и [DONE].
  • Разрыв соединения в середине потока означает незавершённый ответ. Retry, fallback и пользовательскую реакцию держите отдельно от самого парсера.

См. также

Была ли эта страница полезной?