Streaming

Server-Sent Events von Anfang bis Ende: Parsen, Keep-Alives, Denken, Abbrechen, Zeitlimits und wie ein Stream einen Fehler meldet.

Setzen Sie stream: true bei einer Chat-Completion, um Tokens zu empfangen, während sie erzeugt werden, über Server-Sent Events (text/event-stream). Jedes Chat-Modell im Katalog streamt, auch die, die Kontinent über einen Nicht-OpenAI-Upstream erreicht (Claude auf Bedrock und auf Vertex): das Gateway dekodiert deren eigene Formate und gibt OpenAI-förmige Chunks aus.

Embeddings haben keine inkrementelle Form: stream: true an /v1/embeddings ist ein 400, kein still ignoriertes Flag. Ein Vektor als Event-Stream verpackt nützt niemandem, und den Parameter scheinbar anzunehmen würde Sie auf Deltas warten lassen, die nie kommen.

typescript
const stream = await client.chat.completions.create({
  model: "mistral/mistral-small-4",
  messages: [{ role: "user", content: "Schreibe ein Haiku über Europa." }],
  stream: true,
});
 
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
  if (chunk.usage) console.error("\nusage:", chunk.usage);
}

Wire-Format

Jedes Event ist eine data:-Zeile mit einem JSON-Chunk, abgeschlossen durch einen Sentinel:

data: {"id":"chatcmpl-01J…","object":"chat.completion.chunk","created":1721822400,"model":"mistral/mistral-small-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hal"},"finish_reason":null}]}

data: {"id":"chatcmpl-01J…","object":"chat.completion.chunk","created":1721822400,"model":"mistral/mistral-small-4","choices":[{"index":0,"delta":{"content":"lo!"},"finish_reason":null}]}

data: {"id":"chatcmpl-01J…","object":"chat.completion.chunk","created":1721822400,"model":"mistral/mistral-small-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"id":"chatcmpl-01J…","object":"chat.completion.chunk","created":1721822400,"model":"mistral/mistral-small-4","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":8,"total_tokens":11}}

data: [DONE]

Drei Dinge, auf die Sie sich verlassen können:

  • Der erste Chunk nennt meist die Rolle (delta.role: "assistant") und wiederholt sie nie. Garantiert auf den Chat-Pfaden, die Kontinent selbst baut (jedes Modell auf Bedrock, Claude auf Vertex); bei durchreichenden Anbietern gehen deren eigene Chunks unverändert weiter, und einige lassen sie weg. Behandeln Sie die Rolle als optional und nehmen Sie assistant an. (/v1/responses hat weder Chat-Completion-Chunks noch choices[].delta: die benannten Events tragen ihre eigenen delta-Felder mit dem eigentlichen Text, und die Rolle steht als "role": "assistant" am Message-Item.)
  • Der Usage-Chunk hat ein leeres choices-Array. Kontinent injiziert stream_options: {"include_usage": true} upstream, fragt ihn also immer an; setzen Sie stream_options selbst, gewinnt Ihr Wert. Lässt ein Anbieter die Usage trotzdem weg, schätzt das Gateway die Tokens. Abgerechnet wird trotzdem, und der Datensatz wird intern als geschätzt markiert.
  • [DONE] heisst: die Antwort ist vollständig. Es ist das einzige positive Signal, dass nichts fehlt; das negative steht unter Fehler mitten im Stream.

Keep-Alive-Kommentare

Solange das Modell noch arbeitet, schickt das Gateway SSE-Kommentarzeilen, damit niemand auf dem Weg ein denkendes Modell für eine tote Verbindung hält:

: KONTINENT PROCESSING

Ein Kommentar ist kein Event. Nach der SSE-Spezifikation trägt jede Zeile, die mit : beginnt, keine Daten, und jeder konforme Client ignoriert sie. Sie dürfen sie für eine Ladeanzeige nutzen.

Wer den Stream selbst zerlegt, muss Zeilen mit : am Anfang überspringen, bevor JSON.parse aufgerufen wird. : KONTINENT PROCESSING an einen JSON-Parser zu geben wirft, und unbehandelt reisst es die Leseschleife ab. Dieser Fehler zeigt sich nur bei langsamen, teuren Anfragen, weil schnelle vor dem ersten Keep-Alive fertig sind.

Den Stream parsen

Am sichersten ist es, ihn nicht selbst zu parsen. Diese Clients behandeln SSE-Framing, Kommentare, mehrzeilige data:-Felder und Pufferung korrekt:

  • die offiziellen OpenAI-SDKs (baseURL auf https://api.kontinent.ai/v1 setzen)
  • das Vercel AI SDK
  • eventsource-parser, wenn Sie das Framing abgeben, die Chunks aber selbst behandeln wollen

Wenn Sie die Schleife selbst schreiben, muss sie vier Dinge überleben: einen Chunk, der über zwei TCP-Reads verteilt ist, eine Kommentarzeile, den [DONE]-Sentinel und einen Fehler-Chunk.

typescript
const res = await fetch("https://api.kontinent.ai/v1/chat/completions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.KONTINENT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "bedrock/claude-sonnet-5",
    messages: [{ role: "user", content: "Hallo!" }],
    stream: true,
  }),
});
 
// Fehler VOR dem ersten Byte sind reines JSON mit echtem Statuscode.
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}
console.log("generation:", res.headers.get("X-Generation-Id"));
console.log("provider:", res.headers.get("X-Provider"));
 
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let complete = false;
 
// Eine benannte Schleife, damit `break read` aus der inneren beide verlässt.
read: while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
 
  let nl: number;
  while ((nl = buffer.indexOf("\n")) !== -1) {
    const line = buffer.slice(0, nl).trim();
    buffer = buffer.slice(nl + 1);
 
    if (line === "" || line.startsWith(":")) continue; // leer oder Keep-Alive
    if (!line.startsWith("data:")) continue;
 
    const payload = line.slice(5).trim();
    if (payload === "[DONE]") { complete = true; break; }
 
    const chunk = JSON.parse(payload);
 
    // Fehler NACH dem ersten Byte kommen hier an; der Status war schon 200.
    if (chunk.error) {
      throw new Error(`${chunk.error.code}: ${chunk.error.message} (Antwort unvollständig)`);
    }
    if (chunk.usage) console.error("usage:", chunk.usage);
 
    const delta = chunk.choices?.[0]?.delta;
    if (delta?.reasoning) process.stderr.write(delta.reasoning); // Denken
    if (delta?.content) process.stdout.write(delta.content);     // Antwort
  }
  if (complete) break read;
}
 
// EOF ist kein Erfolg. Eine Verbindung, die vor `[DONE]` abbricht, hinterlässt
// eine abgeschnittene Antwort, die wie eine kurze aussieht.
if (!complete) throw new Error("Stream endete ohne [DONE]: Antwort unvollständig");

Diese Schleife liest eine data:-Zeile pro Event. Mehr schickt Kontinent nicht. Die SSE-Spezifikation erlaubt aber mehrere data:-Zeilen in einem Event, die mit Zeilenumbrüchen zu verbinden sind; wer das (oder event:- und id:-Felder) vollständig behandeln will, nimmt einen der Parser oben statt diese Schleife zu erweitern.

Reasoning-Modelle

Das Denken streamt im selben delta und kommt vor der Antwort:

data: {"choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Zehntel ","reasoning_details":[{"type":"reasoning.text","text":"Zehntel ","index":0}]}}]}

data: {"choices":[{"index":0,"delta":{"reasoning":"vergleichen…","reasoning_details":[{"type":"reasoning.text","text":"vergleichen…","index":0}]}}]}

data: {"choices":[{"index":0,"delta":{"content":"9,9 ist größer."}}]}

delta.reasoning ist ein String, den Sie aneinanderhängen und ausgeben können. delta.reasoning_details ist die strukturierte Form, die Sie zurückschicken, um ein denkendes Gespräch fortzusetzen. Siehe Reasoning für den Anfrageparameter, die Detail-Typen und wie verschlüsseltes Denken gemeldet wird.

Ein Reasoning-Modell kann vor seinem ersten Token lange still sein. Es überlegt, es hängt nicht. Die Keep-Alive-Kommentare des Gateways überbrücken diese Lücke, und bei Bedrock und Vertex zählen die Denk-Deltas selbst als Upstream-Aktivität.

Tool-Aufrufe

Tool-Aufrufe streamen als delta.tool_calls, bei jedem Chat-Modell. Die beiden Claude-Wege (Bedrock und Vertex) werden genau in diese Form übersetzt, das Wire-Format verrät also nicht, welcher Upstream geantwortet hat:

data: {"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"ci"}}]}}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ty\":\"Berlin\"}"}}]}}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}

Drei Regeln, und sie stammen von OpenAI:

  • index ist der Schlüssel, nicht die Reihenfolge des Eintreffens. Das erste Fragment eines Aufrufs trägt id, type und function.name, jedes weitere nur noch Argumenttext. Zwei parallele Aufrufe verschränken sich, sammeln Sie also in einer Map mit index als Schlüssel.
  • Die Argumente sind ein JSON-String, der in Stücken ankommt. Ein einzelnes Fragment ist kein gültiges JSON. Erst aneinanderhängen, dann JSON.parse, sobald finish_reason da ist.
  • finish_reason: "tool_calls" beendet den Zug, wenn das Modell ein Tool ausgeführt haben will. Jedes andere Ende, auch "error", bedeutet, dass der Aufruf unvollständig sein kann: Führen Sie nie ein Tool aus einem Stream aus, der nicht sauber endete.

Für die nächste Runde schicken Sie den Assistenten-Zug mit seinen gesammelten tool_calls zurück, danach je Aufruf eine Nachricht {"role": "tool", "tool_call_id": "...", "content": "..."}. Bei einem Reasoning-Modell spielen Sie zusätzlich die reasoning_details dieses Zuges zurück: Claude prüft sein eigenes Denken und setzt kein Denken fort, das es sich nicht selbst zuordnen kann. Die offiziellen SDKs übernehmen das Sammeln für Sie.

Wo ein Upstream einen Tool-Parameter nicht ausdrücken kann, wird die Anfrage mit einem 400 abgelehnt, das das Feld benennt, statt sie ohne ihn zu bedienen. Bei jedem Modell, das Bedrock bedient, betrifft das tool_choice: "none" und parallel_tool_calls: false, auf beiden Claude-Wegen das Erzwingen eines Tools ("required" oder eine benannte Funktion) bei aktiviertem reasoning, was Anthropic ablehnt. Siehe API-Referenz.

/v1/responses streamen

Die Responses-API streamt benannte Events statt anonymer Chunks, jedes mit einer monoton steigenden sequence_number:

event: response.created
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_01J…","status":"in_progress",…}}

event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"reasoning","id":"rs_01J…"}}

event: response.reasoning_text.delta
data: {"type":"response.reasoning_text.delta","sequence_number":2,"item_id":"rs_01J…","output_index":0,"content_index":0,"delta":"Zehntel "}

event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_01J…","output_index":1,"content_index":0,"delta":"9,9 ist größer."}

event: response.completed
data: {"type":"response.completed","sequence_number":11,"response":{"id":"resp_01J…","status":"completed","output":[…],"usage":{…}}}

Der vollständige Eventsatz: response.created, response.output_item.added, response.reasoning_text.delta, response.reasoning_text.done, response.content_part.added, response.output_text.delta, response.output_text.done, response.content_part.done, response.function_call_arguments.delta, response.function_call_arguments.done, response.output_item.done und response.completed. Keep-Alive-Kommentare und die Regel „kein [DONE] nach einem Fehler" gelten hier ebenso. Eine abgebrochene Generierung endet mit response.failed, dessen Response-Objekt status: "failed", ein error und die bis dahin erzeugte Ausgabe trägt.

Ein Tool-Aufruf ist ein eigenes Output-Item vom Typ function_call mit call_id, name und arguments. Die Argumente streamen als response.function_call_arguments.delta-Fragmente und sind erst parsbar, wenn response.function_call_arguments.done sie vollständig wiederholt hat. Für die nächste Runde schicken Sie das Item in input zurück als {"type": "function_call", "call_id": "...", "name": "...", "arguments": "..."}, gefolgt von {"type": "function_call_output", "call_id": "...", "output": "..."}.

Rein verschlüsseltes Denken erzeugt kein response.reasoning_text.delta, weil es keinen Klartext zu streamen gibt. Der Block erreicht Sie aber trotzdem im Reasoning-Item des letzten Events.

Die Anfrageform steht in der API-Referenz.

Abbrechen

Hören Sie auf zu lesen und schliessen Sie die Verbindung, in JavaScript mit AbortController, in Python durch Verlassen des with-Blocks. Kontinent schliesst daraufhin die Upstream-Verbindung, Anbieter, die Abbrüche beachten, stoppen also sofort.

typescript
const controller = new AbortController();
const stream = await client.chat.completions.create(
  { model: "mistral/mistral-small-4", messages, stream: true },
  { signal: controller.signal },
);
// später:
controller.abort();

Ein Abbruch macht die Anfrage nicht kostenlos. Die bis dahin erzeugten Tokens werden abgerechnet. Weil der Anbieter bei einem abgebrochenen Stream seinen Usage-Chunk nie schickt, wird diese Zahl aus dem geschätzt, was das Gateway empfangen hat.

Ein Anbieter kann die Generierung auf seiner Seite auch nach Ihrem Abbruch noch zu Ende rechnen. Der Abbruch stoppt also die Bytes zu Ihnen, aber nicht immer die Arbeit.

Zeitlimits und Keep-Alives

GrenzeWertWas sie misst
Gesamtdauer eines StreamskeineEine lange Antwort ist keine kaputte.
Upstream-Stille90 sStille des Anbieters. Seine eigenen Chunks, auch Denk-Deltas, setzen sie zurück.
Keep-Alive-Kommentarnach 15 s StilleStille Ihnen gegenüber. Beweist, dass die Verbindung lebt.
Load-Balancer-Idle300 sKontinents eigener Rand.

Das Keep-Alive setzt die 90 s bewusst nicht zurück. Täte es das, würde ein mitten in der Generierung gestorbener Anbieter die Verbindung unbegrenzt offen halten, während das Gateway Sie weiter beruhigt. Die zwei Mechanismen beantworten verschiedene Fragen: ist der Client noch erreichbar und arbeitet der Anbieter noch.

Wenn die 90 s greifen, bekommen Sie den Fehler-Chunk aus dem nächsten Abschnitt, keine stille Kürzung.

Fehler mitten im Stream

Definiert das Modell Ausweichanbieter, werden Upstream-Fehler (401/403/429/5xx/Verbindungsfehler) beim nächsten Anbieter erneut versucht, nur vor dem ersten Byte an Sie. Das ist der letzte Moment, in dem ein anderer Anbieter die ganze Antwort noch liefern kann.

Nach dem ersten Byte steht der Statuscode bereits auf 200 und lässt sich nicht mehr ändern. Der Fehler wird deshalb im Stream gemeldet:

data: {"id":"chatcmpl-01J…","object":"chat.completion.chunk","created":1721822400,"model":"bedrock/claude-sonnet-5","provider":"bedrock","error":{"code":"upstream_error","message":"the provider failed mid-generation; the answer is incomplete"},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
  • Das error-Objekt steht auf oberster Ebene, neben den gewöhnlichen Chunk-Feldern.
  • finish_reason ist "error", damit auch ein Client, der nur Finish-Reasons verfolgt, es merkt.
  • provider nennt den Anbieter, der abgebrochen hat. Das macht den Fehler handhabbar.
  • Danach folgt kein [DONE]. Erkennen Sie den Fehler, indem Sie jeden Chunk auf error oder auf finish_reason: "error" prüfen.

Behandeln Sie ihn als wiederholbar auf Ihrer Seite: der Text, den Sie haben, ist unvollständig, und die bereits erzeugten Tokens werden abgerechnet. Fehler vor dem Beginn des Streams sind gewöhnliches JSON mit echtem Statuscode (400, 401, 402, 429, 502, 503). Siehe Fehler behandeln.

Antwortköpfe

Drei Header sagen Ihnen, was mit einer Anfrage passiert ist, gestreamt oder nicht. X-Generation-Id steht auf jeder Antwort, auch auf Fehlern; X-Provider und X-Served-Model stehen auf jeder Antwort, die ein Anbieter bedient hat. Alle Header stehen unter Request- & Response-Header.

HeaderBedeutung
X-Generation-IdId des Nutzungsdatensatzes, den diese Anfrage schreibt
X-Providerder Anbieter, der tatsächlich bedient hat, nach einem möglichen Failover
X-Served-Modeldas Katalogmodell, das tatsächlich bedient hat

X-Generation-Id ist der Schlüssel, um einen einzelnen Aufruf mit Ihren Logs und Ihrer Rechnung abzugleichen. Auf den Endpunkten, deren Antwortkörper Kontinent selbst baut (Bedrock, Vertex Anthropic, /v1/responses), steht dieser Wert im Feld id, und zwar mit Präfix: chatcmpl-<id> bei Chat-Completions, resp_<id> bei /v1/responses. Entfernen Sie das Präfix vor dem Vergleich; wörtlich gleich sind die beiden nicht.

X-Provider und X-Served-Model sind die einzige Stelle, an der Sie einen stillen Ausweich sehen. Für ein Souveränitätsprodukt ist das keine Debugging-Nettigkeit: Sie haben ein Modell auch danach gewählt, wo es läuft, und nach einem Ausweich kam die Antwort möglicherweise von Ihrer zweiten Wahl.

Checkliste für eine robuste Stream-Schleife

  1. HTTP-Status prüfen, bevor der Body gelesen wird.
  2. X-Generation-Id und X-Provider festhalten.
  3. Reads puffern und an Zeilenumbrüchen trennen, denn ein Chunk kann über zwei TCP-Reads gehen.
  4. Leerzeilen und Zeilen mit : am Anfang überspringen.
  5. Bei [DONE] aufhören und jedes andere Ende als unvollständig behandeln.
  6. Jeden Chunk auf error / finish_reason: "error" prüfen.
  7. usage aus dem Chunk mit leerem choices lesen.
  8. delta.reasoning getrennt von delta.content darstellen. Gedanken sind nicht die Antwort.
  9. delta.tool_calls nach index sammeln, function.arguments aneinanderhängen und erst nach finish_reason: "tool_calls" parsen.

Verwandt

Reasoning Fehler behandeln Maximale Verfügbarkeit