Logo VelaStack

Enable AI

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.

$ vela enable ai
src/lib/server/ai.ts
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;
}
src/routes/(app)/ai/+page.svelte
<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>
src/routes/api/chat/+server.ts
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.",
    }),
  });
};
src/routes/api/chat/server.test.ts
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);
  });
});