Streaming lets your interface show the answer as the model writes it. With XField you still send a normal authenticated POST /api/v1/chat/completions. The only change is stream: true, and instead of one JSON object you read a sequence of server-sent events.
This guide builds the whole path: a server route that holds the key and proxies the stream, a browser loop that renders it, and the failure cases you will meet in production.
What the stream looks like
Each event is a line that starts with data:, followed by a blank line. The payload is a chat completion chunk. Text arrives in choices[0].delta.content. On models that think, reasoning arrives first in choices[0].delta.reasoning. The last chunk carries usage with token counts, and the stream ends with a literal data: [DONE].
curl -N "https://xfield.cloud.brutal.ai/api/v1/chat/completions" \
-H "Authorization: Bearer $XFIELD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4.1-flash-1017-uncensored",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [{ "role": "user", "content": "Explain SSE in two sentences." }]
}'The -N flag turns off curl's buffering so you see chunks as they land. Three rules follow from the format:
- Not every chunk has content. Some carry only reasoning, a tool-call fragment, a finish reason, or usage.
- A network read is not an event. Buffer bytes until you have a full event before you parse it.
- Lines starting with
:are keepalive comments. Skip them, along with blank lines.
The server route
Keep the API key on your server. The browser talks to your route, your route talks to XField, and only the text the UI needs goes back down. This route uses the OpenAI SDK in a fetch-style handler.
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.XFIELD_API_KEY,
baseURL: "https://xfield.cloud.brutal.ai/api/v1",
maxRetries: 3, // retries 429 and 5xx with backoff, honors Retry-After
});
export async function POST(req: Request) {
const { messages } = await req.json();
let stream;
try {
stream = await client.chat.completions.create(
{
model: "deepseek-v4.1-flash-1017-uncensored",
messages,
stream: true,
stream_options: { include_usage: true },
},
{ signal: req.signal }, // browser cancels, upstream cancels
);
} catch (err) {
const status = err instanceof OpenAI.APIError ? err.status ?? 502 : 502;
return Response.json({ error: "upstream_error", status }, { status });
}
const enc = new TextEncoder();
const send = (c: ReadableStreamDefaultController, obj: unknown) =>
c.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
const body = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta as
| { content?: string; reasoning?: string }
| undefined;
if (delta?.reasoning) send(controller, { reasoning: delta.reasoning });
if (delta?.content) send(controller, { content: delta.content });
if (chunk.usage) send(controller, { usage: chunk.usage });
}
controller.enqueue(enc.encode("data: [DONE]\n\n"));
} catch {
send(controller, { error: "interrupted" });
} finally {
controller.close();
}
},
cancel() {
stream.controller.abort();
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}The route re-emits a smaller event shape of its own: reasoning, content, usage, or error, then [DONE]. Nothing about the upstream request reaches the browser.
The browser reader loop
The client checks the HTTP status first, then reads bytes, splits on the blank line between events, and dispatches each one. An AbortController backs the Stop button.
type Handlers = {
onReasoning: (t: string) => void;
onContent: (t: string) => void;
onUsage: (u: { prompt_tokens: number; completion_tokens: number }) => void;
};
export async function streamChat(
messages: { role: string; content: string }[],
h: Handlers,
signal: AbortSignal,
): Promise<"done" | "interrupted"> {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) return "interrupted"; // closed without [DONE]
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of event.split("\n")) {
if (!line.startsWith("data:")) continue; // comments, blanks
const data = line.slice(5).trim();
if (data === "[DONE]") return "done";
const msg = JSON.parse(data);
if (msg.error) return "interrupted";
if (msg.reasoning) h.onReasoning(msg.reasoning);
if (msg.content) h.onContent(msg.content);
if (msg.usage) h.onUsage(msg.usage);
}
}
}
}
// Stop button
const controller = new AbortController();
stopButton.onclick = () => controller.abort();Aborting the fetch closes the connection to your route, which fires cancel() on the server stream and aborts the upstream request. You stop paying for tokens nobody will read.
Show reasoning apart from the answer
DeepSeek V4.1 Flash thinks before it answers, at high effort. That reasoning streams before the first content token, sometimes for a while. Render it in its own collapsed "Thinking" section with a running indicator, and put the answer below it.
If your interface has no use for it, send reasoning: { exclude: true } and the chunks never arrive. To skip thinking entirely, send reasoning: { enabled: false }. Either way, read token counts from the final usage chunk rather than counting chunks yourself.
When things go wrong
Errors come back as JSON with an error.code and error.message. How you respond depends on the status:
| Status | Meaning | What to do |
|---|---|---|
| 401 | Missing, unknown, disabled or expired key | Do not retry. Fix the key in Settings › API keys. |
| 429 | Rate limited | Retry with exponential backoff and jitter. |
| 503 | Upstream busy, with Retry-After | Wait the number of seconds given, then retry. |
| Closed mid-stream | Connection dropped after text started | Mark the reply interrupted and offer a retry. |
Status errors happen before the first byte, so the SDK's built-in retries handle 429 and 503 in the route above. A dropped stream is different. Some text has already rendered, so retrying silently would duplicate or contradict it. Keep the partial reply on screen, label it interrupted, keep the user's draft, and let them choose to regenerate.
The same loop in Python
For scripts, notebooks or a Python backend, here is the raw protocol with httpx, including backoff on 429 and Retry-After on 503.
import json, os, random, sys, time
import httpx
URL = "https://xfield.cloud.brutal.ai/api/v1/chat/completions"
API_KEY = os.environ["XFIELD_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def stream_chat(messages, max_attempts=5):
body = {
"model": "deepseek-v4.1-flash-1017-uncensored",
"messages": messages,
"stream": True,
"stream_options": {"include_usage": True},
}
for attempt in range(max_attempts):
with httpx.stream("POST", URL, headers=HEADERS, json=body, timeout=None) as r:
if r.status_code in (429, 503):
wait = float(r.headers.get("Retry-After", 2 ** attempt + random.random()))
time.sleep(wait)
continue
r.raise_for_status() # 401 and other errors stop here
for line in r.iter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
return
chunk = json.loads(data)
if chunk.get("usage"):
print(f"\n[{chunk['usage']['completion_tokens']} tokens]")
for choice in chunk.get("choices", []):
delta = choice.get("delta", {})
if delta.get("reasoning"):
print(delta["reasoning"], end="", flush=True, file=sys.stderr)
if delta.get("content"):
print(delta["content"], end="", flush=True)
raise ConnectionError("stream closed before [DONE]")
raise RuntimeError("gave up after retries")
stream_chat([{"role": "user", "content": "Write a haiku about backpressure."}])Reasoning goes to stderr and the answer to stdout, so you can pipe one without the other. Ctrl-C closes the connection, which cancels the request upstream.
Where to go next
The pattern is the same on every route: /responses and /messages also stream over SSE, with their own event shapes. Pick a model on the models page, and see the quickstart for keys and base URLs. If you do not have access yet, request an invite.