AI SDK + AI Gateway

Stream AI responses into your UI with one SDK, whichever model you choose.

What it is

The AI SDK (ai) gives one API for every model: streamText() on the server and the useChat() hook in the browser, connected by a streamed response. Vercel AI Gateway sits in front of the providers: you name a model as a string such as anthropic/claude-haiku-4.5, and it handles keys, billing, fallbacks and spend limits. Swapping models is a one-line change.

When to use it

  • Chat and assistants
  • Summaries, extraction and classification
  • Any feature that should stream text as it's generated
Official docs ↗

Chat (useChat → streamText)

Ask anything. The reply streams in word by word.

Built as if every call cost money: 5 messages per 30 seconds per visitor, 500 characters in, 300 tokens out.

Show code · src/app/api/ai/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, validateUIMessages } from "ai";
import { labModel } from "@/lib/ai-model";
import { checkLimit, demoLimit, retryIn } from "@/lib/redis";

const MAX_MESSAGES = 20;
const MAX_INPUT_CHARS = 500;

// The chat endpoint useChat talks to. Even with the simulated model, it's
// built as if every call cost money: rate-limited, with capped input and output.
export async function POST(request: Request) {
  const limit = await checkLimit(demoLimit);
  if (limit && !limit.success) {
    return Response.json({ error: `Slow down: try again in ${retryIn(limit.reset)} s.` }, { status: 429 });
  }

  let messages;
  try {
    messages = await validateUIMessages({ messages: ((await request.json()) as { messages?: unknown }).messages });
  } catch {
    return Response.json({ error: "Invalid messages" }, { status: 400 });
  }
  const recent = messages.slice(-MAX_MESSAGES);
  const lastText = recent.at(-1)?.parts.map((p) => (p.type === "text" ? p.text : "")).join("") ?? "";
  if (lastText.length > MAX_INPUT_CHARS) {
    return Response.json({ error: `Messages are limited to ${MAX_INPUT_CHARS} characters.` }, { status: 413 });
  }

  const result = streamText({
    model: labModel(),
    system: "You are the Vercel Feature Lab's assistant. Answer briefly and plainly.",
    messages: await convertToModelMessages(recent),
    maxOutputTokens: 300,
  });

  return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) });
}
Show code · src/app/ai/Chat.tsx
"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";

// useChat keeps the conversation, posts it to the route and renders the reply
// as it streams in. Messages are lists of parts (text, tool calls, files…);
// this chat only uses text.
export function Chat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status, error, stop } = useChat({
    // A fixed id: otherwise useChat makes a random one while the page is
    // prerendered, which Cache Components refuses.
    id: "lab-chat",
    transport: new DefaultChatTransport({ api: "/api/ai/chat" }),
  });
  const busy = status === "submitted" || status === "streaming";

  return (
    <>
      <div className="chat-log" aria-live="polite">
        {messages.length === 0 && <p className="muted small">Ask anything. The reply streams in word by word.</p>}
        {messages.map((m) => (
          <p key={m.id}>
            <strong>{m.role === "user" ? "You" : "Lab"}:</strong>{" "}
            {m.parts.map((part, i) => (part.type === "text" ? <span key={i}>{part.text}</span> : null))}
          </p>
        ))}
      </div>
      {error && <p className="error" role="alert">{error.message.includes("429") || error.message.includes("Slow down") ? "Slow down a little and try again." : "Something went wrong. Try again."}</p>}
      <form
        className="form-row"
        onSubmit={(e) => {
          e.preventDefault();
          const text = input.trim();
          if (!text || busy) return;
          sendMessage({ text });
          setInput("");
        }}
      >
        <label className="grow">Message<input value={input} onChange={(e) => setInput(e.target.value)} maxLength={500} placeholder="What does streamText do?" /></label>
        {busy ? <button type="button" className="ghost" onClick={stop}>Stop</button> : <button type="submit" disabled={!input.trim()}>Send</button>}
      </form>
    </>
  );
}
Show code · src/lib/ai-model.ts
import { type LanguageModel, simulateReadableStream } from "ai";
import { MockLanguageModelV4 } from "ai/test";

// The model behind /ai. By default it's SIMULATED: the AI SDK's mock model,
// which streams a canned reply without calling any provider, so nothing can
// ever be billed. Set AI_MODEL to a gateway model id (for example
// "anthropic/claude-haiku-4.5") to use a real model through Vercel AI Gateway;
// the rest of the code doesn't change. (AI Gateway needs a card on file, even
// for its free tier.)

export const isSimulated = () => !process.env.AI_MODEL;

export function labModel(): LanguageModel {
  return process.env.AI_MODEL || simulatedModel();
}

type PromptMessage = { role: string; content: unknown };

// The text of the newest user message in a model prompt.
export function lastUserText(prompt: PromptMessage[]) {
  const last = [...prompt].reverse().find((m) => m.role === "user");
  if (!last || !Array.isArray(last.content)) return "";
  return last.content
    .map((part: { type?: string; text?: string }) => (part.type === "text" ? part.text ?? "" : ""))
    .join(" ")
    .trim();
}

export function simulatedAnswer(question: string) {
  const asked = question.length > 120 ? `${question.slice(0, 117)}…` : question;
  return [
    `This is a simulated reply: no AI model is connected, so nothing is billed.`,
    `You asked: "${asked}".`,
    `Here's what just happened. useChat sent your message to /api/ai/chat. The route checked the rate limit, converted the chat`,
    `into model messages and called streamText. The model, a mock from the AI SDK's test tools, emitted this text word by word,`,
    `and toUIMessageStream turned it into the stream your browser is reading now. With AI_MODEL set, the same route would`,
    `stream a real answer through Vercel AI Gateway instead.`,
  ].join(" ");
}

function simulatedModel() {
  return new MockLanguageModelV4({
    provider: "lab-simulated",
    modelId: "simulated",
    doStream: async ({ prompt }) => {
      const words = simulatedAnswer(lastUserText(prompt)).split(/(?<=\s)/);
      return {
        stream: simulateReadableStream({
          initialDelayInMs: 300,
          chunkDelayInMs: 35,
          chunks: [
            { type: "text-start", id: "text-1" },
            ...words.map((delta) => ({ type: "text-delta" as const, id: "text-1", delta })),
            { type: "text-end", id: "text-1" },
            {
              type: "finish",
              finishReason: { unified: "stop", raw: undefined },
              logprobs: undefined,
              usage: {
                inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
                outputTokens: { total: words.length, text: words.length, reasoning: undefined },
              },
            },
          ],
        }),
      };
    },
  });
}