Cookbook

Terminal chat

A streaming chat in the terminal that keeps history and treats every incomplete answer as an error.

Download and configure

Install Python 3 or Node 22 and download the complete examples from the recipe archive. The archive has the files shown below, including the browser app and local stream checks. Choose an exact model ID from the public catalogue and export your inference key from a secret store.

bash
curl --fail "https://aihub.belive.sg/docs/examples/tokenhub.zip" -o tokenhub-examples.zip
unzip tokenhub-examples.zip
cd tokenhub-examples
cp .env.example .env
# Edit .env: set TOKENHUB_API_KEY and TOKENHUB_MODEL.
# Keep .env local. Scripts read process environment, not the .env file itself.
set -a
. ./.env
set +a
# Use the API base for the environment shown by this docs site.
export TOKENHUB_BASE_URL="https://tokenhub-api.belive.sg/api/v1"

The loop sends the conversation so far, prints tokens as they arrive and keeps at most 20 messages. A failed answer is dropped from the history, so the next turn does not build on it.

The stream reader

A stream is finished only when [DONE] arrives; an SDK iterator that simply stops may be a truncation. This reader follows the stream contract: it skips keep-alive comments, reads usage from the final chunk whose choices is empty, throws on an error frame sent after HTTP 200, and throws when the body ends without data: [DONE].

typescript
// A strict reader for AI Hub's chat SSE stream. No dependencies.
//
// It follows the stream contract on /docs/streaming:
// - lines starting with ":" are keep-alive comments and are skipped;
// - the last chunk before [DONE] carries `usage` and an EMPTY `choices` array;
// - a failure after HTTP 200 arrives as one `{"error": …}` frame, and no [DONE] follows;
// - a body that ends without `data: [DONE]` is truncated and is an error, not a short answer.

export type Usage = { prompt_tokens: number; completion_tokens: number; total_tokens: number };

export class StreamError extends Error {
  readonly kind: "error_frame" | "truncated" | "http";
  constructor(message: string, kind: "error_frame" | "truncated" | "http") {
    super(message);
    this.kind = kind;
  }
}

/**
 * SSE allows CRLF, LF and CR line endings. Normalise to LF, keeping a trailing CR until the next
 * read shows whether an LF follows it.
 */
export function normalizeLineEndings(buffer: string): string {
  const hold = buffer.endsWith("\r");
  return (hold ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n") + (hold ? "\r" : "");
}

/** Yields text deltas and returns the final usage. Throws StreamError on any abnormal end. */
export async function* readChatStream(res: Response): AsyncGenerator<string, Usage | null> {
  if (!res.ok || !res.body) {
    const text = await res.text().catch(() => "");
    throw new StreamError(`HTTP ${res.status}: ${text.slice(0, 500)}`, "http");
  }
  const decoder = new TextDecoder();
  const reader = res.body.getReader();
  let buffer = "";
  let usage: Usage | null = null;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer = normalizeLineEndings(buffer + decoder.decode(value, { stream: true }));
    // Frames end at a blank line; a frame can arrive split across reads.
    let end: number;
    while ((end = buffer.indexOf("\n\n")) !== -1) {
      const frame = buffer.slice(0, end);
      buffer = buffer.slice(end + 2);
      const data = frame
        .split("\n")
        .filter((line) => line.startsWith("data:"))
        .map((line) => line.slice(5).trimStart())
        .join("\n");
      if (data === "") continue; // a comment-only frame
      if (data === "[DONE]") return usage;
      const chunk = JSON.parse(data);
      if (chunk.error) throw new StreamError(chunk.error.message ?? "stream failed", "error_frame");
      if (chunk.usage) usage = chunk.usage;
      const text = chunk.choices?.[0]?.delta?.content;
      if (typeof text === "string" && text !== "") yield text;
    }
  }
  // A terminator with no blank line after it still counts.
  if (buffer.replace(/\r$/, "").trim() === "data: [DONE]") return usage;
  throw new StreamError("the stream ended without data: [DONE]", "truncated");
}

The chat loop

// A streaming terminal chat with history. No dependencies; Node 22.
// Needs TOKENHUB_BASE_URL, TOKENHUB_API_KEY and TOKENHUB_MODEL.
// Run: node --experimental-strip-types chat.ts   (type /exit or press Ctrl-D to quit)
import { createInterface } from "node:readline/promises";
import { readChatStream, StreamError } from "./sse.ts";

const base = (process.env.TOKENHUB_BASE_URL ?? "").replace(/\/+$/, "");
const key = process.env.TOKENHUB_API_KEY ?? "";
const model = process.env.TOKENHUB_MODEL ?? "";
if (!base || !key || !model) throw new Error("set TOKENHUB_BASE_URL, TOKENHUB_API_KEY and TOKENHUB_MODEL");

/** Keep the last N messages so a long session cannot grow the request without bound. */
const MAX_HISTORY = 20;
const history: { role: "user" | "assistant"; content: string }[] = [];
const rl = createInterface({ input: process.stdin, output: process.stdout });

for (;;) {
  let line: string;
  try {
    line = (await rl.question("you> ")).trim();
  } catch {
    break; // Ctrl-D
  }
  if (line === "/exit") break;
  if (line === "") continue;
  history.push({ role: "user", content: line });
  history.splice(0, Math.max(0, history.length - MAX_HISTORY));

  const res = await fetch(`${base}/chat/completions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ model, messages: history, stream: true, max_tokens: 1000 }),
  });
  let answer = "";
  process.stdout.write("bot> ");
  try {
    const stream = readChatStream(res);
    let step = await stream.next();
    while (!step.done) {
      answer += step.value;
      process.stdout.write(step.value);
      step = await stream.next();
    }
    process.stdout.write("\n");
    const usage = step.value;
    if (usage) console.log(`[${usage.prompt_tokens} in, ${usage.completion_tokens} out; request ${res.headers.get("x-generation-id")}]`);
    history.push({ role: "assistant", content: answer });
  } catch (err) {
    // A failed or truncated answer is not kept in the history: the next turn would build on it.
    history.pop();
    const kind = err instanceof StreamError ? err.kind : "unknown";
    console.error(`\n[${kind}] ${(err as Error).message}`);
  }
}
rl.close();