AI Chat

intermediate

Production-ready AI chat with streaming responses, conversation history, and provider abstraction supporting OpenAI and Anthropic.

aichatstreamingopenaianthropicvercel-ai-sdk
Tested on201619TS5.9
$ bunx sinew add ai/ai-chat
Interactive demo coming soon

1The Problem

Building AI chat interfaces requires handling:

  • Streaming responses for real-time UX
  • Conversation history management
  • Provider abstraction for flexibility
  • Session persistence across page reloads

2The Solution

Use the Vercel AI SDK with provider abstraction supporting OpenAI and Anthropic. Persist conversation history in Upstash Redis for serverless compatibility.

3Files

lib/ai/providers.ts

lib/ai/providers.tsTypeScript
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";

export type AIProvider = "openai" | "anthropic";

const openai = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const anthropic = createAnthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

export const models = {
  openai: {
    default: openai("gpt-4o"),
    fast: openai("gpt-4o-mini"),
    reasoning: openai("o1"),
  },
  anthropic: {
    default: anthropic("claude-sonnet-4-20250514"),
    fast: anthropic("claude-3-5-haiku-20241022"),
    reasoning: anthropic("claude-sonnet-4-20250514"),
  },
} as const;

export type ModelType = "default" | "fast" | "reasoning";

export function getProvider(): AIProvider {
  const provider = process.env.AI_PROVIDER as AIProvider;
  if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
    return "anthropic";
  }
  return "openai";
}

export function getModel(type: ModelType = "default") {
  const provider = getProvider();
  return models[provider][type];
}

lib/ai/chat.ts

lib/ai/chat.tsTypeScript
import { streamText, type ModelMessage } from "ai";
import { Redis } from "@upstash/redis";
import { getModel, type ModelType } from "./providers";

const redis = Redis.fromEnv();

export async function streamChat({
  sessionId,
  message,
  systemPrompt,
  modelType = "default",
}: {
  sessionId: string;
  message: string;
  systemPrompt?: string;
  modelType?: ModelType;
}) {
  const messages = await getMessages(sessionId);
  const userMessage: ModelMessage = { role: "user", content: message };
  messages.push(userMessage);

  const model = getModel(modelType);

  const result = streamText({
    model,
    system: systemPrompt ?? "You are a helpful assistant.",
    messages,
    onFinish: async ({ text }) => {
      try {
        messages.push({ role: "assistant", content: text });
        await saveMessages(sessionId, messages);
      } catch (error) {
        console.error("Failed to persist chat history:", error);
      }
    },
  });

  return result;
}

async function getMessages(sessionId: string): Promise<ModelMessage[]> {
  // @upstash/redis deserializes JSON automatically — read the array directly.
  const data = await redis.get<ModelMessage[]>(`chat:${sessionId}`);
  return data ?? [];
}

async function saveMessages(sessionId: string, messages: ModelMessage[]) {
  // Store the raw array (no JSON.stringify) and set a 24h TTL in one call.
  await redis.set(`chat:${sessionId}`, messages, { ex: 60 * 60 * 24 });
}

app/api/chat/route.ts

app/api/chat/route.tsTypeScript
import { NextRequest } from "next/server";
import { streamChat } from "@/lib/ai/chat";

export const runtime = "edge";

export async function POST(req: NextRequest) {
  const { message, sessionId, systemPrompt } = await req.json();

  if (!message || !sessionId) {
    return Response.json({ error: "Missing required fields" }, { status: 400 });
  }

  const result = await streamChat({ sessionId, message, systemPrompt });
  return result.toUIMessageStreamResponse();
}

components/chat-ui.tsx

components/chat-ui.tsxTSX
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useState, type FormEvent } from "react";

function messageText(message: UIMessage): string {
  return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
}

export function ChatUI({ sessionId }: { sessionId: string }) {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status } = useChat({
    // The server rebuilds history from Redis using sessionId, so only the
    // latest user message is sent.
    transport: new DefaultChatTransport({
      api: "/api/chat",
      prepareSendMessagesRequest: ({ messages }) => {
        const last = messages[messages.length - 1];
        return { body: { sessionId, message: last ? messageText(last) : "" } };
      },
    }),
  });

  const isLoading = status === "submitted" || status === "streaming";

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    const text = input.trim();
    if (!text || isLoading) return;
    sendMessage({ text });
    setInput("");
  };

  return (
    <div className="flex h-full flex-col">
      <div className="flex-1 space-y-4 overflow-y-auto p-4">
        {messages.map((message) => (
          <div key={message.id} className={message.role === "user" ? "text-right" : "text-left"}>
            <p className="inline-block rounded-lg bg-gray-100 px-4 py-2">{messageText(message)}</p>
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit} className="border-t p-4">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          disabled={isLoading}
          className="w-full rounded-lg border px-4 py-2"
          placeholder="Type a message..."
        />
      </form>
    </div>
  );
}

4Dependencies

$ bun add ai @ai-sdk/openai @ai-sdk/anthropic @upstash/redis

5Configuration

Environment Variables

| Variable | Description | Required | | -------------------------- | ----------------------- | ------------------------ | | AI_PROVIDER | "openai" or "anthropic" | No (defaults to openai) | | OPENAI_API_KEY | OpenAI API key | Yes (if using OpenAI) | | ANTHROPIC_API_KEY | Anthropic API key | Yes (if using Anthropic) | | UPSTASH_REDIS_REST_URL | Upstash Redis URL | Yes | | UPSTASH_REDIS_REST_TOKEN | Upstash Redis token | Yes |

6Usage

import { ChatUI } from "@/components/chat-ui";

export default function ChatPage() {
  return (
    <div className="h-screen">
      <ChatUI sessionId="user-123-session-1" />
    </div>
  );
}
TSX

7Alternatives

  • LangChain - Extensive ecosystem with built-in memory patterns
  • LlamaIndex - Excellent for RAG applications
  • Amazon Bedrock - Multiple model providers with AWS integration

Related patterns