diff --git a/.gitignore b/.gitignore index 00735b6ca..004cdbc01 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,5 @@ apps/*/dist/ *.logs .cursor/ + +demos/use_cases/vercel-ai-sdk/.env.local diff --git a/demos/use_cases/vercel-ai-sdk/.dockerignore b/demos/use_cases/vercel-ai-sdk/.dockerignore new file mode 100644 index 000000000..ec5c2a835 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/.dockerignore @@ -0,0 +1,58 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Testing +coverage +.nyc_output + +# Next.js +.next/ +out/ +dist +build + +# Misc +.DS_Store +*.pem + +# Debug +*.log + +# Local env files +.env +.env*.local +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Git +.git +.gitignore + +# Database +*.db +*.db-journal +.data/ + +# Tests +playwright-report/ +test-results/ diff --git a/demos/use_cases/vercel-ai-sdk/.env.example b/demos/use_cases/vercel-ai-sdk/.env.example new file mode 100644 index 000000000..97d07a0ab --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/.env.example @@ -0,0 +1,12 @@ +# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` +AUTH_SECRET=**** + +# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob +BLOB_READ_WRITE_TOKEN=**** + +# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres +POSTGRES_URL=**** + +# Instructions to create a Redis store here: +# https://vercel.com/docs/redis +REDIS_URL=**** diff --git a/demos/use_cases/vercel-ai-sdk/.gitignore b/demos/use_cases/vercel-ai-sdk/.gitignore new file mode 100644 index 000000000..bf62c6316 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.env +.vercel +.env*.local + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* + +# IDE +.cursor/ +.vscode/ diff --git a/demos/use_cases/vercel-ai-sdk/README.md b/demos/use_cases/vercel-ai-sdk/README.md new file mode 100644 index 000000000..9065d70b7 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/README.md @@ -0,0 +1,72 @@ +# Plano Demo: Next.js + AI SDK + Observability (Jaeger) + +This is a **quick demo of Plano’s capabilities** as an LLM gateway: + +- **Routing & model selection**: all LLM traffic goes through Plano. +- **OpenAI-compatible gateway**: the app talks to Plano using the OpenAI API shape. +- **Observability**: traces exported to **Jaeger** so you can inspect requests end-to-end. + +The app also includes **tool calling with generative UI**: +- `getWeather` +- `getCurrencyExchange` + +Both use open and free APIs. + +## Quickstart + +### 1) Start Plano + Jaeger (Docker) + +From `demos/use_cases/vercel-ai-sdk/`: + +```bash +docker compose up +``` + +- **Plano Gateway**: `http://localhost:12000/v1` +- **Jaeger UI**: `http://localhost:16686` + +### 2) Point the app at Plano + +Create `demos/use_cases/vercel-ai-sdk/.env.local`: + +```bash +# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` +AUTH_SECRET=**** + +# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob +BLOB_READ_WRITE_TOKEN=**** + +# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres +POSTGRES_URL=**** + +# Instructions to create a Redis store here: +# https://vercel.com/docs/redis +REDIS_URL=**** + +PLANO_BASE_URL=http://localhost:12000/v1 + +``` + + + +### 3) Start the Next.js app (local) + +In a second terminal (same directory): + +```bash +npm install --legacy-peer-deps +npm run dev +``` + +Now open the app at `http://localhost:3000`. + +> **Note**: This repo uses fast-moving dependencies (AI SDK betas, React 19, Next.js 16). npm’s strict peer dependency resolver can fail installs; passing `--legacy-peer-deps` helps keep the install unblocked. + +## What to try + +- **Currency**: “Convert 100 USD to EUR” +- **Weather**: “What’s the weather in San Francisco?” + +## Tracing + +Open Jaeger (`http://localhost:16686`) and search traces for the Plano service to see routing + latency breakdowns. diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts new file mode 100644 index 000000000..024ff518e --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts @@ -0,0 +1,84 @@ +"use server"; + +import { z } from "zod"; + +import { createUser, getUser } from "@/lib/db/queries"; + +import { signIn } from "./auth"; + +const authFormSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}); + +export type LoginActionState = { + status: "idle" | "in_progress" | "success" | "failed" | "invalid_data"; +}; + +export const login = async ( + _: LoginActionState, + formData: FormData +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + await signIn("credentials", { + email: validatedData.email, + password: validatedData.password, + redirect: false, + }); + + return { status: "success" }; + } catch (error) { + if (error instanceof z.ZodError) { + return { status: "invalid_data" }; + } + + return { status: "failed" }; + } +}; + +export type RegisterActionState = { + status: + | "idle" + | "in_progress" + | "success" + | "failed" + | "user_exists" + | "invalid_data"; +}; + +export const register = async ( + _: RegisterActionState, + formData: FormData +): Promise => { + try { + const validatedData = authFormSchema.parse({ + email: formData.get("email"), + password: formData.get("password"), + }); + + const [user] = await getUser(validatedData.email); + + if (user) { + return { status: "user_exists" } as RegisterActionState; + } + await createUser(validatedData.email, validatedData.password); + await signIn("credentials", { + email: validatedData.email, + password: validatedData.password, + redirect: false, + }); + + return { status: "success" }; + } catch (error) { + if (error instanceof z.ZodError) { + return { status: "invalid_data" }; + } + + return { status: "failed" }; + } +}; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..588ff6a5c --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +// biome-ignore lint/performance/noBarrelFile: "Required" +export { GET, POST } from "@/app/(auth)/auth"; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 000000000..dca565c5a --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { signIn } from "@/app/(auth)/auth"; +import { isDevelopmentEnvironment } from "@/lib/constants"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const redirectUrl = searchParams.get("redirectUrl") || "/"; + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (token) { + return NextResponse.redirect(new URL("/", request.url)); + } + + return signIn("guest", { redirect: true, redirectTo: redirectUrl }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts new file mode 100644 index 000000000..b8bc9e1f1 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts @@ -0,0 +1,13 @@ +import type { NextAuthConfig } from "next-auth"; + +export const authConfig = { + pages: { + signIn: "/login", + newUser: "/", + }, + providers: [ + // added later in auth.ts since it requires bcrypt which is only compatible with Node.js + // while this file is also used in non-Node.js environments + ], + callbacks: {}, +} satisfies NextAuthConfig; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts new file mode 100644 index 000000000..dbebb1d98 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts @@ -0,0 +1,95 @@ +import { compare } from "bcrypt-ts"; +import NextAuth, { type DefaultSession } from "next-auth"; +import type { DefaultJWT } from "next-auth/jwt"; +import Credentials from "next-auth/providers/credentials"; +import { DUMMY_PASSWORD } from "@/lib/constants"; +import { createGuestUser, getUser } from "@/lib/db/queries"; +import { authConfig } from "./auth.config"; + +export type UserType = "guest" | "regular"; + +declare module "next-auth" { + interface Session extends DefaultSession { + user: { + id: string; + type: UserType; + } & DefaultSession["user"]; + } + + // biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required" + interface User { + id?: string; + email?: string | null; + type: UserType; + } +} + +declare module "next-auth/jwt" { + interface JWT extends DefaultJWT { + id: string; + type: UserType; + } +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + credentials: {}, + async authorize({ email, password }: any) { + const users = await getUser(email); + + if (users.length === 0) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const [user] = users; + + if (!user.password) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const passwordsMatch = await compare(password, user.password); + + if (!passwordsMatch) { + return null; + } + + return { ...user, type: "regular" }; + }, + }), + Credentials({ + id: "guest", + credentials: {}, + async authorize() { + const [guestUser] = await createGuestUser(); + return { ...guestUser, type: "guest" }; + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id as string; + token.type = user.type; + } + + return token; + }, + session({ session, token }) { + if (session.user) { + session.user.id = token.id; + session.user.type = token.type; + } + + return session; + }, + }, +}); diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx new file mode 100644 index 000000000..666feee36 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useActionState, useEffect, useState } from "react"; + +import { AuthForm } from "@/components/auth-form"; +import { SubmitButton } from "@/components/submit-button"; +import { toast } from "@/components/toast"; +import { type LoginActionState, login } from "../actions"; + +export default function Page() { + const router = useRouter(); + + const [email, setEmail] = useState(""); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + login, + { + status: "idle", + } + ); + + const { update: updateSession } = useSession(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs + useEffect(() => { + if (state.status === "failed") { + toast({ + type: "error", + description: "Invalid credentials!", + }); + } else if (state.status === "invalid_data") { + toast({ + type: "error", + description: "Failed validating your submission!", + }); + } else if (state.status === "success") { + setIsSuccessful(true); + updateSession(); + router.refresh(); + } + }, [state.status]); + + const handleSubmit = (formData: FormData) => { + setEmail(formData.get("email") as string); + formAction(formData); + }; + + return ( +
+
+
+

Sign In

+

+ Use your email and password to sign in +

+
+ + Sign in +

+ {"Don't have an account? "} + + Sign up + + {" for free."} +

+
+
+
+ ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx new file mode 100644 index 000000000..ff2f1e80f --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useActionState, useEffect, useState } from "react"; +import { AuthForm } from "@/components/auth-form"; +import { SubmitButton } from "@/components/submit-button"; +import { toast } from "@/components/toast"; +import { type RegisterActionState, register } from "../actions"; + +export default function Page() { + const router = useRouter(); + + const [email, setEmail] = useState(""); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + register, + { + status: "idle", + } + ); + + const { update: updateSession } = useSession(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs + useEffect(() => { + if (state.status === "user_exists") { + toast({ type: "error", description: "Account already exists!" }); + } else if (state.status === "failed") { + toast({ type: "error", description: "Failed to create account!" }); + } else if (state.status === "invalid_data") { + toast({ + type: "error", + description: "Failed validating your submission!", + }); + } else if (state.status === "success") { + toast({ type: "success", description: "Account created successfully!" }); + + setIsSuccessful(true); + updateSession(); + router.refresh(); + } + }, [state.status]); + + const handleSubmit = (formData: FormData) => { + setEmail(formData.get("email") as string); + formAction(formData); + }; + + return ( +
+
+
+

Sign Up

+

+ Create an account with your email and password +

+
+ + Sign Up +

+ {"Already have an account? "} + + Sign in + + {" instead."} +

+
+
+
+ ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts new file mode 100644 index 000000000..19f73c1ac --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { generateText, type UIMessage } from "ai"; +import { cookies } from "next/headers"; +import type { VisibilityType } from "@/components/visibility-selector"; +import { titlePrompt } from "@/lib/ai/prompts"; +import { getTitleModel } from "@/lib/ai/providers"; +import { + deleteMessagesByChatIdAfterTimestamp, + getMessageById, + updateChatVisibilityById, +} from "@/lib/db/queries"; +import { getTextFromMessage } from "@/lib/utils"; + +export async function saveChatModelAsCookie(model: string) { + const cookieStore = await cookies(); + cookieStore.set("chat-model", model); +} + +export async function generateTitleFromUserMessage({ + message, +}: { + message: UIMessage; +}) { + const { text: title } = await generateText({ + model: getTitleModel(), + system: titlePrompt, + prompt: getTextFromMessage(message), + }); + + return title; +} + +export async function deleteTrailingMessages({ id }: { id: string }) { + const [message] = await getMessageById({ id }); + + await deleteMessagesByChatIdAfterTimestamp({ + chatId: message.chatId, + timestamp: message.createdAt, + }); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + await updateChatVisibilityById({ chatId, visibility }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts new file mode 100644 index 000000000..48352e976 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts @@ -0,0 +1,113 @@ +import { createUIMessageStream, JsonToSseTransformStream } from "ai"; +import { differenceInSeconds } from "date-fns"; +import { auth } from "@/app/(auth)/auth"; +import { + getChatById, + getMessagesByChatId, + getStreamIdsByChatId, +} from "@/lib/db/queries"; +import type { Chat } from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; +import type { ChatMessage } from "@/lib/types"; +import { getStreamContext } from "../../route"; + +export async function GET( + _: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: chatId } = await params; + + const streamContext = getStreamContext(); + const resumeRequestedAt = new Date(); + + if (!streamContext) { + return new Response(null, { status: 204 }); + } + + if (!chatId) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + let chat: Chat | null; + + try { + chat = await getChatById({ id: chatId }); + } catch { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (!chat) { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (chat.visibility === "private" && chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + + const streamIds = await getStreamIdsByChatId({ chatId }); + + if (!streamIds.length) { + return new ChatSDKError("not_found:stream").toResponse(); + } + + const recentStreamId = streamIds.at(-1); + + if (!recentStreamId) { + return new ChatSDKError("not_found:stream").toResponse(); + } + + const emptyDataStream = createUIMessageStream({ + // biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist" + execute: () => {}, + }); + + const stream = await streamContext.resumableStream(recentStreamId, () => + emptyDataStream.pipeThrough(new JsonToSseTransformStream()) + ); + + /* + * For when the generation is streaming during SSR + * but the resumable stream has concluded at this point. + */ + if (!stream) { + const messages = await getMessagesByChatId({ id: chatId }); + const mostRecentMessage = messages.at(-1); + + if (!mostRecentMessage) { + return new Response(emptyDataStream, { status: 200 }); + } + + if (mostRecentMessage.role !== "assistant") { + return new Response(emptyDataStream, { status: 200 }); + } + + const messageCreatedAt = new Date(mostRecentMessage.createdAt); + + if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) { + return new Response(emptyDataStream, { status: 200 }); + } + + const restoredStream = createUIMessageStream({ + execute: ({ writer }) => { + writer.write({ + type: "data-appendMessage", + data: JSON.stringify(mostRecentMessage), + transient: true, + }); + }, + }); + + return new Response( + restoredStream.pipeThrough(new JsonToSseTransformStream()), + { status: 200 } + ); + } + + return new Response(stream, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts new file mode 100644 index 000000000..e9a5819bf --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts @@ -0,0 +1,317 @@ +import { geolocation } from "@vercel/functions"; +import { + convertToModelMessages, + createUIMessageStream, + JsonToSseTransformStream, + smoothStream, + stepCountIs, + streamText, +} from "ai"; +import { after } from "next/server"; +import { + createResumableStreamContext, + type ResumableStreamContext, +} from "resumable-stream"; +import { auth, type UserType } from "@/app/(auth)/auth"; +import { entitlementsByUserType } from "@/lib/ai/entitlements"; +import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; +import { getLanguageModel } from "@/lib/ai/providers"; +import { getWeather } from "@/lib/ai/tools/get-weather"; +import { getCurrencyExchange } from "@/lib/ai/tools/get-currency-exchange"; +import { isProductionEnvironment } from "@/lib/constants"; +import { + createStreamId, + deleteChatById, + getChatById, + getMessageCountByUserId, + getMessagesByChatId, + saveChat, + saveMessages, + updateChatTitleById, + updateMessage, +} from "@/lib/db/queries"; +import type { DBMessage } from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; +import type { ChatMessage } from "@/lib/types"; +import { convertToUIMessages, generateUUID } from "@/lib/utils"; +import { generateTitleFromUserMessage } from "../../actions"; +import { type PostRequestBody, postRequestBodySchema } from "./schema"; + +export const maxDuration = 60; + +let globalStreamContext: ResumableStreamContext | null = null; + +export function getStreamContext() { + if (!globalStreamContext) { + try { + globalStreamContext = createResumableStreamContext({ + waitUntil: after, + }); + } catch (error: any) { + if (error.message.includes("REDIS_URL")) { + console.log( + " > Resumable streams are disabled due to missing REDIS_URL" + ); + } else { + console.error(error); + } + } + } + + return globalStreamContext; +} + +export async function POST(request: Request) { + let requestBody: PostRequestBody; + + try { + const json = await request.json(); + requestBody = postRequestBodySchema.parse(json); + } catch (_) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + try { + const { id, message, messages, selectedChatModel, selectedVisibilityType } = + requestBody; + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const userType: UserType = session.user.type; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 24, + }); + + if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { + return new ChatSDKError("rate_limit:chat").toResponse(); + } + + // Check if this is a tool approval flow (all messages sent) + const isToolApprovalFlow = Boolean(messages); + + const chat = await getChatById({ id }); + let messagesFromDb: DBMessage[] = []; + let titlePromise: Promise | null = null; + + if (chat) { + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + // Only fetch messages if chat already exists and not tool approval + if (!isToolApprovalFlow) { + messagesFromDb = await getMessagesByChatId({ id }); + } + } else if (message?.role === "user") { + // Save chat immediately with placeholder title + await saveChat({ + id, + userId: session.user.id, + title: "New chat", + visibility: selectedVisibilityType, + }); + + // Start title generation in parallel (don't await) + titlePromise = generateTitleFromUserMessage({ message }); + } + + // Use all messages for tool approval, otherwise DB messages + new message + const uiMessages = isToolApprovalFlow + ? (messages as ChatMessage[]) + : [...convertToUIMessages(messagesFromDb), message as ChatMessage]; + + const { longitude, latitude, city, country } = geolocation(request); + + const requestHints: RequestHints = { + longitude, + latitude, + city, + country, + }; + + // Only save user messages to the database (not tool approval responses) + if (message?.role === "user") { + await saveMessages({ + messages: [ + { + chatId: id, + id: message.id, + role: "user", + parts: message.parts, + attachments: [], + createdAt: new Date(), + }, + ], + }); + } + + const streamId = generateUUID(); + await createStreamId({ streamId, chatId: id }); + + const stream = createUIMessageStream({ + // Pass original messages for tool approval continuation + originalMessages: isToolApprovalFlow ? uiMessages : undefined, + execute: async ({ writer: dataStream }) => { + // Handle title generation in parallel + if (titlePromise) { + titlePromise.then((title) => { + updateChatTitleById({ chatId: id, title }); + dataStream.write({ type: "data-chat-title", data: title }); + }); + } + + const isReasoningModel = + selectedChatModel.includes("reasoning") || + selectedChatModel.includes("thinking"); + + const result = streamText({ + model: getLanguageModel(selectedChatModel), + system: systemPrompt({ selectedChatModel, requestHints }), + messages: await convertToModelMessages(uiMessages), + stopWhen: stepCountIs(5), + experimental_activeTools: isReasoningModel + ? [] + : ["getWeather", "getCurrencyExchange"], + experimental_transform: isReasoningModel + ? undefined + : smoothStream({ chunking: "word" }), + providerOptions: isReasoningModel + ? { + anthropic: { + thinking: { type: "enabled", budgetTokens: 10_000 }, + }, + } + : undefined, + tools: { + getWeather, + getCurrencyExchange, + }, + experimental_telemetry: { + isEnabled: isProductionEnvironment, + functionId: "stream-text", + }, + }); + + result.consumeStream(); + + dataStream.merge( + result.toUIMessageStream({ + sendReasoning: true, + }) + ); + }, + generateId: generateUUID, + onFinish: async ({ messages: finishedMessages }) => { + if (isToolApprovalFlow) { + // For tool approval, update existing messages (tool state changed) and save new ones + for (const finishedMsg of finishedMessages) { + const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id); + if (existingMsg) { + // Update existing message with new parts (tool state changed) + await updateMessage({ + id: finishedMsg.id, + parts: finishedMsg.parts, + }); + } else { + // Save new message + await saveMessages({ + messages: [ + { + id: finishedMsg.id, + role: finishedMsg.role, + parts: finishedMsg.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + }, + ], + }); + } + } + } else if (finishedMessages.length > 0) { + // Normal flow - save all finished messages + await saveMessages({ + messages: finishedMessages.map((currentMessage) => ({ + id: currentMessage.id, + role: currentMessage.role, + parts: currentMessage.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + })), + }); + } + }, + onError: () => { + return "Oops, an error occurred!"; + }, + }); + + const streamContext = getStreamContext(); + + if (streamContext) { + try { + const resumableStream = await streamContext.resumableStream( + streamId, + () => stream.pipeThrough(new JsonToSseTransformStream()) + ); + if (resumableStream) { + return new Response(resumableStream); + } + } catch (error) { + console.error("Failed to create resumable stream:", error); + } + } + + return new Response(stream.pipeThrough(new JsonToSseTransformStream())); + } catch (error) { + const vercelId = request.headers.get("x-vercel-id"); + + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + + // Check for Vercel AI Gateway credit card error + if ( + error instanceof Error && + error.message?.includes( + "AI Gateway requires a valid credit card on file to service requests" + ) + ) { + return new ChatSDKError("bad_request:activate_gateway").toResponse(); + } + + console.error("Unhandled error in chat API:", error, { vercelId }); + return new ChatSDKError("offline:chat").toResponse(); + } +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const chat = await getChatById({ id }); + + if (chat?.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts new file mode 100644 index 000000000..60a708acd --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +const textPartSchema = z.object({ + type: z.enum(["text"]), + text: z.string().min(1).max(2000), +}); + +const filePartSchema = z.object({ + type: z.enum(["file"]), + mediaType: z.enum(["image/jpeg", "image/png"]), + name: z.string().min(1).max(100), + url: z.string().url(), +}); + +const partSchema = z.union([textPartSchema, filePartSchema]); + +const userMessageSchema = z.object({ + id: z.string().uuid(), + role: z.enum(["user"]), + parts: z.array(partSchema), +}); + +// For tool approval flows, we accept all messages (more permissive schema) +const messageSchema = z.object({ + id: z.string(), + role: z.string(), + parts: z.array(z.any()), +}); + +export const postRequestBodySchema = z.object({ + id: z.string().uuid(), + // Either a single new message or all messages (for tool approvals) + message: userMessageSchema.optional(), + messages: z.array(messageSchema).optional(), + selectedChatModel: z.string(), + selectedVisibilityType: z.enum(["public", "private"]), +}); + +export type PostRequestBody = z.infer; diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts new file mode 100644 index 000000000..4e4e4f3ca --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,68 @@ +import { put } from "@vercel/blob"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/(auth)/auth"; + +// Use Blob instead of File since File is not available in Node.js environment +const FileSchema = z.object({ + file: z + .instanceof(Blob) + .refine((file) => file.size <= 5 * 1024 * 1024, { + message: "File size should be less than 5MB", + }) + // Update the file type based on the kind of files you want to accept + .refine((file) => ["image/jpeg", "image/png"].includes(file.type), { + message: "File type should be JPEG or PNG", + }), +}); + +export async function POST(request: Request) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (request.body === null) { + return new Response("Request body is empty", { status: 400 }); + } + + try { + const formData = await request.formData(); + const file = formData.get("file") as Blob; + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + + const validatedFile = FileSchema.safeParse({ file }); + + if (!validatedFile.success) { + const errorMessage = validatedFile.error.errors + .map((error) => error.message) + .join(", "); + + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + // Get filename from formData since Blob doesn't have name property + const filename = (formData.get("file") as File).name; + const fileBuffer = await file.arrayBuffer(); + + try { + const data = await put(`${filename}`, fileBuffer, { + access: "public", + }); + + return NextResponse.json(data); + } catch (_error) { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } + } catch (_error) { + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 } + ); + } +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts new file mode 100644 index 000000000..23615e305 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = Number.parseInt(searchParams.get("limit") || "10", 10); + const startingAfter = searchParams.get("starting_after"); + const endingBefore = searchParams.get("ending_before"); + + if (startingAfter && endingBefore) { + return new ChatSDKError( + "bad_request:api", + "Only one of starting_after or ending_before can be provided." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const chats = await getChatsByUserId({ + id: session.user.id, + limit, + startingAfter, + endingBefore, + }); + + return Response.json(chats); +} + +export async function DELETE() { + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const result = await deleteAllChatsByUserId({ userId: session.user.id }); + + return Response.json(result, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts new file mode 100644 index 000000000..2c0ce3f78 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts @@ -0,0 +1,75 @@ +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get("chatId"); + + if (!chatId) { + return new ChatSDKError( + "bad_request:api", + "Parameter chatId is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:vote").toResponse(); + } + + const votes = await getVotesByChatId({ id: chatId }); + + return Response.json(votes, { status: 200 }); +} + +export async function PATCH(request: Request) { + const { + chatId, + messageId, + type, + }: { chatId: string; messageId: string; type: "up" | "down" } = + await request.json(); + + if (!chatId || !messageId || !type) { + return new ChatSDKError( + "bad_request:api", + "Parameters chatId, messageId, and type are required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError("not_found:vote").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:vote").toResponse(); + } + + await voteMessage({ + chatId, + messageId, + type, + }); + + return new Response("Message voted", { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 000000000..1bd569376 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,82 @@ +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { Suspense } from "react"; + +import { auth } from "@/app/(auth)/auth"; +import { Chat } from "@/components/chat"; +import { DataStreamHandler } from "@/components/data-stream-handler"; +import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models"; +import { getChatById, getMessagesByChatId } from "@/lib/db/queries"; +import { convertToUIMessages } from "@/lib/utils"; + +export default function Page(props: { params: Promise<{ id: string }> }) { + return ( + }> + + + ); +} + +async function ChatPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const chat = await getChatById({ id }); + + if (!chat) { + redirect("/"); + } + + const session = await auth(); + + if (!session) { + redirect("/api/auth/guest"); + } + + if (chat.visibility === "private") { + if (!session.user) { + return notFound(); + } + + if (session.user.id !== chat.userId) { + return notFound(); + } + } + + const messagesFromDb = await getMessagesByChatId({ + id, + }); + + const uiMessages = convertToUIMessages(messagesFromDb); + + const cookieStore = await cookies(); + const chatModelFromCookie = cookieStore.get("chat-model"); + + if (!chatModelFromCookie) { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx b/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx new file mode 100644 index 000000000..f6d4f5fee --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx @@ -0,0 +1,35 @@ +import { cookies } from "next/headers"; +import Script from "next/script"; +import { Suspense } from "react"; +import { AppSidebar } from "@/components/app-sidebar"; +import { DataStreamProvider } from "@/components/data-stream-provider"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { auth } from "../(auth)/auth"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> +