Add AI chat to your SvelteKit app with the Vercel AI SDK. Choose Vercel AI Gateway, OpenAI, or Anthropic with --provider; the pattern writes src/lib/server/ai.ts, whose languageModel() hands the configured model to any server code, and a /api/chat endpoint that validates the conversation and streams the reply with streamText. A demo page at /ai is built on the Chat class from @ai-sdk/svelte, with a Stop button and errors shown inline. The provider's API key goes in .env and is read through $env/dynamic/private, so it never reaches the browser, and the endpoint answers 503 until it is set. With auth enabled, only signed-in users can chat and the demo page moves behind sign-in. A server test covers the endpoint without calling the model. Projects built with adapter-static are refused, because the key needs a server.
import { createGateway } from "ai";
import { env } from "$env/dynamic/private";
/** The `.env` key holding the Vercel AI Gateway API key. */
export const API_KEY = "AI_GATEWAY_API_KEY";
/**
* The model the AI routes talk to, or `undefined` while AI_GATEWAY_API_KEY is
* blank. The key is read on every call, so a new value in `.env` needs no code
* change. The gateway routes `creator/model` ids to their provider; any id from
* https://vercel.com/ai-gateway/models works in place of this one.
*/
export function languageModel() {
const apiKey = env[API_KEY];
return apiKey
? createGateway({ apiKey })("anthropic/claude-sonnet-5")
: undefined;
}
<script lang="ts">
import { Chat } from "@ai-sdk/svelte";
import { Button } from "$lib/components/ui/button";
import { Textarea } from "$lib/components/ui/textarea";
// Posts to /api/chat (src/routes/api/chat/+server.ts) and streams the reply
// into `chat.messages`. This page is a demo, and it is yours to delete.
const chat = new Chat({});
let input = $state("");
const busy = $derived(
chat.status === "submitted" || chat.status === "streaming",
);
function send(event: SubmitEvent) {
event.preventDefault();
const text = input.trim();
if (!text || busy) return;
chat.sendMessage({ text });
input = "";
}
// Enter sends; Shift+Enter starts a new line.
function onkeydown(
event: KeyboardEvent & { currentTarget: HTMLTextAreaElement },
) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}
</script>
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 px-4 py-10">
<div>
<h1 class="text-2xl font-semibold">AI chat</h1>
<p class="text-muted-foreground text-sm">
Replies stream from <code>src/routes/api/chat/+server.ts</code>. Pick the
model in
<code>src/lib/server/ai.ts</code>.
</p>
</div>
<div class="flex flex-1 flex-col gap-4" aria-live="polite">
{#each chat.messages as message (message.id)}
<div
class={message.role === "user"
? "bg-primary text-primary-foreground ml-auto max-w-[80%] rounded-lg px-4 py-2"
: "max-w-[80%]"}
>
{#each message.parts as part, index (index)}
{#if part.type === "text"}
<p class="whitespace-pre-wrap">{part.text}</p>
{/if}
{/each}
</div>
{:else}
<p class="text-muted-foreground">
Ask anything to start the conversation.
</p>
{/each}
{#if chat.status === "submitted"}
<p class="text-muted-foreground text-sm">Thinking…</p>
{/if}
{#if chat.error}
<div
role="alert"
class="border-destructive text-destructive flex items-center justify-between gap-4 rounded-md border px-4 py-2 text-sm"
>
<span>{chat.error.message}</span>
<Button variant="outline" size="sm" onclick={() => chat.regenerate()}
>Retry</Button
>
</div>
{/if}
</div>
<form
onsubmit={send}
class="bg-background sticky bottom-0 flex items-end gap-2 py-2"
>
<Textarea
bind:value={input}
{onkeydown}
rows={1}
placeholder="Send a message"
aria-label="Message"
class="max-h-40 min-h-10 resize-none"
/>
{#if busy}
<Button type="button" variant="outline" onclick={() => chat.stop()}
>Stop</Button
>
{:else}
<Button type="submit" disabled={!input.trim()}>Send</Button>
{/if}
</form>
</div>
import { text } from "@sveltejs/kit";
import { dev } from "$app/environment";
import {
convertToModelMessages,
createUIMessageStreamResponse,
safeValidateUIMessages,
streamText,
toUIMessageStream,
} from "ai";
import { API_KEY, languageModel } from "$lib/server/ai";
import type { RequestHandler } from "./$types";
// Sent ahead of every conversation: give the assistant its role here.
const INSTRUCTIONS =
"You are a helpful assistant. Keep answers short unless asked for detail.";
/**
* Where `new Chat()` from @ai-sdk/svelte posts by default: the conversation so
* far comes in, the model's reply streams back. Every reply is billed to your
* API key, so only signed-in users get one.
*
* Errors are plain text, which `Chat` shows as `chat.error.message`.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
return text("Sign in to chat.", { status: 401 });
}
const body = await request.json().catch(() => null);
const messages = await safeValidateUIMessages({ messages: body?.messages });
if (!messages.success) {
return text("Expected a JSON body with a messages array.", { status: 400 });
}
const model = languageModel();
if (!model) {
return text(`Set ${API_KEY} in .env to start chatting.`, { status: 503 });
}
const result = streamText({
model,
instructions: INSTRUCTIONS,
messages: await convertToModelMessages(messages.data),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
// Provider errors (a bad key, an unknown model id) reach the chat in
// dev only; in production they stay in the server log.
onError: (error) =>
dev && error instanceof Error
? error.message
: "The model could not answer.",
}),
});
};
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";
// None of these requests reach the model, so they pass without an API key and
// cost nothing.
describe("POST /api/chat", () => {
it("should return 401 when signed out", async (context) => {
const response = await context.request
.post("/api/chat" satisfies Match<RouteId>)
.send({
messages: [
{ id: "1", role: "user", parts: [{ type: "text", text: "Hello" }] },
],
});
expect(response.status).toBe(401);
});
it("should return 400 if the body is not JSON", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent
.post("/api/chat" satisfies Match<RouteId>)
.set("Content-Type", "application/json")
.send("not json");
expect(response.status).toBe(400);
});
it("should return 400 if messages are missing", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent
.post("/api/chat" satisfies Match<RouteId>)
.send({});
expect(response.status).toBe(400);
});
it("should return 400 if messages are malformed", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent
.post("/api/chat" satisfies Match<RouteId>)
.send({ messages: [{ role: "user", content: "Hello" }] });
expect(response.status).toBe(400);
});
});
# AI SDK (Vercel AI Gateway)
AI_GATEWAY_API_KEY=