Recipes

A streaming chatbot

A chatbot that streams, keeps its conversation, and never puts your API key in a browser. Two files: a route on your server and a component that reads from it.

The server route#

app/api/chat/route.ts
// app/api/chat/route.ts
import OpenAI from "openai";
 
const client = new OpenAI({
baseURL: "https://ai.upfyn.com/v1",
apiKey: process.env.UPFYN_API_KEY, // server-only: never NEXT_PUBLIC_
maxRetries: 0,
});
 
const SYSTEM = "You are a support assistant. Be brief. Say when you do not know.";
 
export async function POST(req: Request) {
const { messages } = await req.json();
 
// Never trust the client's message list wholesale — cap it, and own the system prompt.
const recent = messages.slice(-20);
 
const stream = await client.chat.completions.create({
model: "upfyn-yuva",
messages: [{ role: "system", content: SYSTEM }, ...recent],
stream: true,
max_tokens: 800,
});
 
const encoder = new TextEncoder();
return new Response(
new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
// A failed turn arrives as a frame, not a status code.
const err = (chunk as { error?: { message: string } }).error;
if (err) {
controller.enqueue(encoder.encode(`\n\n[error: ${err.message}]`));
break;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) controller.enqueue(encoder.encode(delta));
}
} finally {
controller.close();
}
},
}),
{
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
"X-Accel-Buffering": "no", // stop a proxy buffering the whole stream
},
},
);
}

What matters here

  • The key never leaves the server. The browser talks to your route; your route talks to Upfyn.
  • The system prompt is yours. It is set server-side so a client cannot replace it.
  • The history is capped. A client that posts ten thousand messages would otherwise cost you ten thousand messages of input tokens.
  • Error frames are handled. Once a stream starts the status is already 200, so a mid-stream failure arrives as a frame — a client that only reads delta shows a silently truncated answer.

The browser side#

components/Chat.tsx
"use client";
import { useState } from "react";
 
export function Chat() {
const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
 
async function send() {
const question = input.trim();
if (!question || busy) return;
 
const next = [...messages, { role: "user", content: question }];
setMessages([...next, { role: "assistant", content: "" }]);
setInput("");
setBusy(true);
 
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: next }),
});
 
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let answer = "";
 
while (true) {
const { done, value } = await reader.read();
if (done) break;
answer += decoder.decode(value, { stream: true });
// Replace the last message rather than appending a new one each tick.
setMessages([...next, { role: "assistant", content: answer }]);
}
setBusy(false);
}
 
return ( /* ...your UI... */ null );
}
  • An empty assistant message is appended first, then filled — so the bubble appears immediately and the user sees it working.
  • decoder.decode(value, { stream: true }) matters: a multi-byte character can be split across two reads, and without it you get replacement characters mid-word.

Cost is not visible on this route

Streaming reports the settled charge only when you send stream_options.include_usage, and that final frame has no content — you would need to read it separately from the text you forward. Add it if you meter users; see Credits.

Worth adding next#

  • Rate-limit your own route per user, or one visitor can spend your whole balance.
  • Persist the conversation, or use a session and stop sending history entirely.
  • Tag requests with chat_ref so one conversation is one thread in your logs.