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.tsimport 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
deltashows 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 sendstream_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_refso one conversation is one thread in your logs.
