Skip to content
NewDeepSeek V4.1 Flash 1017 Uncensored is live, with 1M tokens of context.Read the launch
Journal

Build a chat interface that streams

Stream chat completions from XField into a browser: a small server proxy, a reader loop, reasoning shown apart, and errors, retries and cancellation handled.

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].

Shell
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.

app/api/chat/route.ts
TypeScript
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.

chat-client.ts
TypeScript
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:

StatusMeaningWhat to do
401Missing, unknown, disabled or expired keyDo not retry. Fix the key in Settings › API keys.
429Rate limitedRetry with exponential backoff and jitter.
503Upstream busy, with Retry-AfterWait the number of seconds given, then retry.
Closed mid-streamConnection dropped after text startedMark 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.

stream_chat.py
Python
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.

Keep your stack.
Lose the refusals.

Tell us what you are building. We reply with an invitation, a key and pricing for your volume.