Streaming
Set stream to true and the reply arrives as server-sent events while the model writes it: reasoning first, then the answer, then a last chunk with the finish reason and the usage. Use it whenever a person is waiting for the text, and for long replies.
POST https://api.shannon-ai.com/v1/chat/completions
The OpenAI SDKs give you the chunks one by one. Read each chunk by what it carries: a piece of reasoning, a piece of the answer, a tool call, or the end of the reply.
from openai import OpenAI, APIError
client = OpenAI(
api_key="YOUR_API_KEY",
base_url="https://api.shannon-ai.com/v1",
)
stream = client.chat.completions.create(
model="shannon-3",
messages=[{"role": "user", "content": "Explain server-sent events in three sentences."}],
stream=True,
)
answer, reasoning, tool_calls = [], [], []
finish_reason = usage = None
try:
for chunk in stream:
if chunk.usage:
usage = chunk.usage
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
thought = getattr(delta, "reasoning_content", None)
if thought:
reasoning.append(thought)
if delta.content:
answer.append(delta.content)
print(delta.content, end="", flush=True)
for call in delta.tool_calls or []:
tool_calls.append((call.id, call.function.name, call.function.arguments))
if choice.finish_reason:
finish_reason = choice.finish_reason
except APIError as error:
# A failure after the stream started arrives as an error chunk.
print(f"\nstream failed: {error}")
print(f"\n{finish_reason} {usage}") import OpenAI from "openai";
const client = new OpenAI({
apiKey: "YOUR_API_KEY",
baseURL: "https://api.shannon-ai.com/v1",
});
const stream = await client.chat.completions.create({
model: "shannon-3",
messages: [{ role: "user", content: "Explain server-sent events in three sentences." }],
stream: true,
});
let answer = "";
let reasoning = "";
const toolCalls = [];
let finishReason = null;
let usage = null;
try {
for await (const chunk of stream) {
if (chunk.usage) usage = chunk.usage;
const choice = chunk.choices?.[0];
if (!choice) continue;
const delta = choice.delta;
if (delta.reasoning_content) reasoning += delta.reasoning_content;
if (delta.content) {
answer += delta.content;
process.stdout.write(delta.content);
}
for (const call of delta.tool_calls ?? []) {
toolCalls.push({ id: call.id, name: call.function.name, arguments: call.function.arguments });
}
if (choice.finish_reason) finishReason = choice.finish_reason;
}
} catch (error) {
// A failure after the stream started arrives as an error chunk.
console.error("\nstream failed:", error.message);
}
console.log("\n", finishReason, usage); curl -N https://api.shannon-ai.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "shannon-3",
"messages": [{"role": "user", "content": "Explain server-sent events in three sentences."}],
"stream": true
}' How a stream is framed
- The reply has status
200and the headerscontent-type: text/event-streamandcache-control: no-cache. - Each event is one line that starts with
data:and is followed by an empty line. The text afterdata:is one JSON chunk. There are noevent:orid:lines. - A line that starts with a colon, such as
: ping, is a comment that keeps the connection open. Skip it. - The stream ends with
data: [DONE]. A stream that stops without it is incomplete.
Chunks in order
Every chunk is a chat.completion.chunk object:
{
"id": "chatcmpl-5f0c1e7a9b3d4c62a8e1f07d2b46c9a3",
"object": "chat.completion.chunk",
"created": 1791625200,
"model": "shannon-3",
"choices": [
{
"index": 0,
"delta": {
"content": "Server-sent"
},
"finish_reason": null
}
]
} All chunks of one stream carry the same id, created and model. choices always holds one entry. Its delta holds what is new, and finish_reason is null until the last chunk.
| Key in delta | Type | Description | Sent by |
|---|---|---|---|
role | string | Always assistant. It arrives once, at the start of the stream. | Hosted open-weight models, shannon-2-lite, shannon-2-pro |
reasoning_content | string | A piece of the model's reasoning. Join the pieces in order. | Hosted open-weight models, Shannon 3 family, shannon-1.6-pro, shannon-coder-1 |
content | string | A piece of the answer. Join the pieces in order. | All models |
tool_calls | array | One complete tool call. See Tool call deltas. | All models |
Reasoning comes first, then the answer, then any tool calls. Read a chunk by the keys in its delta, not by its place in the stream: a delta can hold role together with the first piece of text, and the last one is empty.
A whole stream as it travels. To keep the lines short, id, object, created and model are left out of each chunk here.
data: {"choices":[{"index":0,"delta":{"reasoning_content":"Three sentences:"},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"reasoning_content":" what it is, how it is framed, why it is used."},"finish_reason":null}]}
: ping
data: {"choices":[{"index":0,"delta":{"content":"Server-sent"},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"content":" events let a server push text to a client over one HTTP reply."},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1190,"completion_tokens":84,"total_tokens":1274}}
data: [DONE] Reasoning deltas
A model that reasons sends its reasoning in delta.reasoning_content before the answer. Keep the two texts apart: show the reasoning as a separate, collapsible part, or leave it out.
The hosted open-weight models send no reasoning when reasoning_effort is off, and none when the request has a response_format.
shannon-2-lite and shannon-2-pro reason before they answer as well. On this endpoint they send : thinking comment lines while they do, and then the answer.
reasoning_content is not in the typed models of the OpenAI SDKs. In Python read it with getattr(delta, "reasoning_content", None); in JavaScript delta.reasoning_content works as it is.
Tool call deltas
A tool call arrives whole: one chunk per call, whose delta holds the call with the complete arguments string. index numbers the calls of the reply from 0. Code that joins arguments piece by piece works unchanged, because it joins one piece.
{
"tool_calls": [
{
"index": 0,
"id": "call_3d9a7c1e5b2f4a60c8e1d7f09b24a6c5",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}"
}
}
]
} After the calls, the last chunk has finish_reason tool_calls.
When the request has tools, shannon-1.6-lite, shannon-1.6-pro and shannon-coder-1 send the answer text as one delta at the end of the reply.
The last chunk
The last chunk before data: [DONE] has an empty delta, the finish_reason and the usage of the request, all in the same chunk. Every stream has it; you do not need stream_options.
{
"id": "chatcmpl-5f0c1e7a9b3d4c62a8e1f07d2b46c9a3",
"object": "chat.completion.chunk",
"created": 1791625200,
"model": "shannon-3",
"choices": [
{
"index": 0,
"delta": {},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1190,
"completion_tokens": 84,
"total_tokens": 1274
}
} On the hosted open-weight models usage also has prompt_tokens_details.cached_tokens and completion_tokens_details.reasoning_tokens.
After a request with web_search: true whose search found something, the last chunk also carries sources, and one chunk before it carries the citation marks as delta.annotations. Built-in Web Search
| finish_reason | Description | Sent by |
|---|---|---|
stop | The model finished its answer, or a stop string appeared. | All models |
tool_calls | The reply holds one or more tool calls. | All models |
length | The reply was cut at the output limit. | Shannon 3 family, shannon-1.6-lite, shannon-1.6-pro, shannon-coder-1 |
The fields of usage are described with the endpoint. Chat Completions
Keep-alive lines
While the model works and has nothing to send yet, the stream carries comment lines. They hold no data. An SSE parser skips them by itself; code that reads raw lines must skip every line that starts with a colon.
| Line | Sent by | When |
|---|---|---|
: ping | Shannon 3 family, shannon-1.6-lite, shannon-1.6-pro, shannon-coder-1 | Every 15 seconds. |
: keepalive | Hosted open-weight models | Every 15 seconds while the model works. |
: thinking | shannon-2-lite, shannon-2-pro | While the model reasons. |
Errors inside a stream
Once a stream has started its status is 200, so a failure arrives inside the stream: a chunk with an error member where choices would be.
data: {"error":{"message":"The model backend failed to answer. Please retry.","type":"api_error","code":null,"param":null}}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1190,"completion_tokens":12,"total_tokens":1202}}
data: [DONE] After the error chunk the last chunk, with finish_reason and usage, and data: [DONE] still follow. Check every chunk for error. Without that check a reply that failed halfway looks complete.
| Type | Code | Message | When |
|---|---|---|---|
api_error | The model backend failed to answer. Please retry. | The model failed, or wrote nothing. Send the request again. | |
rate_limit_error | Shannon routes are temporarily busy. Please retry. | The model is busy at this moment. Wait a few seconds and send the request again. | |
invalid_request_error | context_length_exceeded | The conversation is longer than the model's context window. Shorten it before you send it again. The message text varies. |
The hosted open-weight models send the error member with type and message only.
The OpenAI Python and JavaScript SDKs raise an error chunk as an API error while you iterate, so wrap the loop in your usual error handling.
Status codes, the error body and what to retry have their own page. Error Handling
Closing the connection
Closing the connection stops the delivery, not the request. The model writes the reply to its end and the request is charged as if you had read all of it. To pay for less, ask for less: set a lower max_tokens on a model that applies it.
A stream can also end early on its way to you, for example on a network break or while a server is being updated. It then stops without the last chunk and without data: [DONE]. Treat that reply as incomplete and send the request again.
Reading a stream without an SDK
A correct reader does five things: it sends errors that arrive before the stream to its error handling, splits the body into lines, skips comment lines, stops at data: [DONE], and checks each chunk for error.
import json
import requests
response = requests.post(
"https://api.shannon-ai.com/v1/chat/completions",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"model": "shannon-3",
"messages": [{"role": "user", "content": "Explain server-sent events in three sentences."}],
"stream": True,
},
stream=True,
)
# 1. Errors before the stream are plain JSON with their own status.
if response.status_code != 200:
raise RuntimeError(f"{response.status_code}: {response.text}")
answer, done = [], False
for raw in response.iter_lines(): # 2. one line at a time
line = raw.decode("utf-8")
if not line.startswith("data: "): # 3. empty lines and ": ping" comments
continue
data = line[len("data: "):]
if data == "[DONE]": # 4. the end of the stream
done = True
break
chunk = json.loads(data)
if "error" in chunk: # 5. a failure inside the stream
raise RuntimeError(chunk["error"]["message"])
delta = chunk["choices"][0]["delta"]
if delta.get("content"):
answer.append(delta["content"])
if chunk.get("usage"):
print(chunk["choices"][0]["finish_reason"], chunk["usage"])
if not done:
raise RuntimeError("the stream ended before [DONE]")
print("".join(answer)) const response = await fetch("https://api.shannon-ai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "shannon-3",
messages: [{ role: "user", content: "Explain server-sent events in three sentences." }],
stream: true,
}),
});
// 1. Errors before the stream are plain JSON with their own status.
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const decoder = new TextDecoder();
let buffer = "";
let answer = "";
let done = false;
read: for await (const bytes of response.body) {
buffer += decoder.decode(bytes, { stream: true });
const lines = buffer.split("\n"); // 2. one line at a time
buffer = lines.pop(); // keep the unfinished line
for (const line of lines) {
if (!line.startsWith("data: ")) continue; // 3. empty lines and ": ping" comments
const data = line.slice(6);
if (data === "[DONE]") { // 4. the end of the stream
done = true;
break read;
}
const chunk = JSON.parse(data);
if (chunk.error) throw new Error(chunk.error.message); // 5. a failure inside the stream
const choice = chunk.choices[0];
if (choice.delta.content) answer += choice.delta.content;
if (chunk.usage) console.log(choice.finish_reason, chunk.usage);
}
}
if (!done) throw new Error("the stream ended before [DONE]");
console.log(answer); The Python sample uses the requests package. The JavaScript sample runs on Node.js 18 or later.
Streams of the other formats
The Responses and Messages endpoints stream as well. Their frames carry an event: line with the name of the event and a data: line with its JSON. Each page lists its events in order.
| In the stream | Chat Completions | Responses | Messages |
|---|---|---|---|
| Frames | data: lines | event: and data: lines | event: and data: lines |
| Reasoning arrives in | delta.reasoning_content | response.reasoning_summary_text.delta | thinking_delta |
| Answer text arrives in | delta.content | response.output_text.delta | text_delta |
| Usage arrives in | The last chunk | response.completed | message_delta |
| The stream ends with | The last chunk, then data: [DONE] | response.completed | message_stop |