From e69964028e6cd46afc39dd4ee0b8c6e95b6e5cc3 Mon Sep 17 00:00:00 2001 From: Musa Date: Thu, 8 Jan 2026 15:20:35 -0800 Subject: [PATCH 01/11] demo(vercel-ai-sdk): add Plano+Jaeger quickstart + config --- demos/use_cases/vercel-ai-sdk/.dockerignore | 58 ++++++++++ demos/use_cases/vercel-ai-sdk/.gitignore | 48 +++++++++ demos/use_cases/vercel-ai-sdk/LICENSE | 13 +++ demos/use_cases/vercel-ai-sdk/README.md | 72 +++++++++++++ demos/use_cases/vercel-ai-sdk/biome.jsonc | 51 +++++++++ demos/use_cases/vercel-ai-sdk/components.json | 20 ++++ demos/use_cases/vercel-ai-sdk/config.yaml | 37 +++++++ .../vercel-ai-sdk/docker-compose.yaml | 39 +++++++ .../use_cases/vercel-ai-sdk/drizzle.config.ts | 16 +++ .../vercel-ai-sdk/instrumentation.ts | 5 + demos/use_cases/vercel-ai-sdk/next.config.ts | 19 ++++ .../vercel-ai-sdk/playwright.config.ts | 100 ++++++++++++++++++ .../vercel-ai-sdk/postcss.config.mjs | 8 ++ demos/use_cases/vercel-ai-sdk/proxy.ts | 59 +++++++++++ demos/use_cases/vercel-ai-sdk/tsconfig.json | 35 ++++++ .../vercel-ai-sdk/vercel-template.json | 19 ++++ demos/use_cases/vercel-ai-sdk/vercel.json | 3 + 17 files changed, 602 insertions(+) create mode 100644 demos/use_cases/vercel-ai-sdk/.dockerignore create mode 100644 demos/use_cases/vercel-ai-sdk/.gitignore create mode 100644 demos/use_cases/vercel-ai-sdk/LICENSE create mode 100644 demos/use_cases/vercel-ai-sdk/README.md create mode 100644 demos/use_cases/vercel-ai-sdk/biome.jsonc create mode 100644 demos/use_cases/vercel-ai-sdk/components.json create mode 100644 demos/use_cases/vercel-ai-sdk/config.yaml create mode 100644 demos/use_cases/vercel-ai-sdk/docker-compose.yaml create mode 100644 demos/use_cases/vercel-ai-sdk/drizzle.config.ts create mode 100644 demos/use_cases/vercel-ai-sdk/instrumentation.ts create mode 100644 demos/use_cases/vercel-ai-sdk/next.config.ts create mode 100644 demos/use_cases/vercel-ai-sdk/playwright.config.ts create mode 100644 demos/use_cases/vercel-ai-sdk/postcss.config.mjs create mode 100644 demos/use_cases/vercel-ai-sdk/proxy.ts create mode 100644 demos/use_cases/vercel-ai-sdk/tsconfig.json create mode 100644 demos/use_cases/vercel-ai-sdk/vercel-template.json create mode 100644 demos/use_cases/vercel-ai-sdk/vercel.json 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/.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/LICENSE b/demos/use_cases/vercel-ai-sdk/LICENSE new file mode 100644 index 000000000..f8a36dbb3 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. 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/biome.jsonc b/demos/use_cases/vercel-ai-sdk/biome.jsonc new file mode 100644 index 000000000..ba27d8fd7 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/biome.jsonc @@ -0,0 +1,51 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["ultracite"], + "files": { + "includes": [ + "**/*", + "!components/ui", + "!lib/utils.ts", + "!hooks/use-mobile.ts" + ] + }, + "linter": { + "rules": { + "suspicious": { + /* Needs more work to fix */ + "noExplicitAny": "off", + + /* Allow for Tailwind @ rules */ + "noUnknownAtRules": "off", + + /* Allowing console for debugging */ + "noConsole": "off", + + /* Needed for generateUUID() */ + "noBitwiseOperators": "off" + }, + "style": { + /* Allowing magic numbers */ + "noMagicNumbers": "off", + + /* Needs more work to fix */ + "noNestedTernary": "off" + }, + "nursery": { + /* Too many false positives */ + "noUnnecessaryConditions": "off" + }, + "complexity": { + /* Needs more work to fix */ + "noExcessiveCognitiveComplexity": "off", + + /* This one has false positives. It's a bit... iffy 😉 */ + "useSimplifiedLogicExpression": "off" + }, + "a11y": { + /* Needs more work to fix */ + "noSvgWithoutTitle": "off" + } + } + } +} diff --git a/demos/use_cases/vercel-ai-sdk/components.json b/demos/use_cases/vercel-ai-sdk/components.json new file mode 100644 index 000000000..388ec1774 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/demos/use_cases/vercel-ai-sdk/config.yaml b/demos/use_cases/vercel-ai-sdk/config.yaml new file mode 100644 index 000000000..4d033d090 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/config.yaml @@ -0,0 +1,37 @@ +version: v0.3.0 + +listeners: + - type: model + name: model_1 + address: 0.0.0.0 + port: 12000 + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + default: true + + - model: openai/gpt-5.2 + access_key: $OPENAI_API_KEY + + - model: openai/gpt-4.1-mini + access_key: $OPENAI_API_KEY + +model_aliases: + gpt-4-mini: + target: openai/gpt-4o-mini + + gpt-4o: + target: openai/gpt-4o + + gpt-5.2: + target: openai/gpt-5.2 + + gpt-4.1-mini: + target: openai/gpt-4.1-mini + +tracing: + random_sampling: 100 diff --git a/demos/use_cases/vercel-ai-sdk/docker-compose.yaml b/demos/use_cases/vercel-ai-sdk/docker-compose.yaml new file mode 100644 index 000000000..f5912bc34 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/docker-compose.yaml @@ -0,0 +1,39 @@ +services: + # Plano Gateway - LLM routing and observability + plano: + build: + context: ../../../ + dockerfile: Dockerfile + container_name: plano + restart: unless-stopped + ports: + - "12000:12000" # Model gateway + environment: + - ARCH_CONFIG_PATH=/app/arch_config.yaml + - OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set} + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 + volumes: + - ./config.yaml:/app/arch_config.yaml:ro + depends_on: + - jaeger + networks: + - plano-network + + # Jaeger - Distributed tracing + jaeger: + build: + context: ../../shared/jaeger + container_name: jaeger-tracing + restart: unless-stopped + ports: + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + environment: + - COLLECTOR_OTLP_ENABLED=true + networks: + - plano-network + +networks: + plano-network: + driver: bridge diff --git a/demos/use_cases/vercel-ai-sdk/drizzle.config.ts b/demos/use_cases/vercel-ai-sdk/drizzle.config.ts new file mode 100644 index 000000000..f782b00ac --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/drizzle.config.ts @@ -0,0 +1,16 @@ +import { config } from "dotenv"; +import { defineConfig } from "drizzle-kit"; + +config({ + path: ".env.local", +}); + +export default defineConfig({ + schema: "./lib/db/schema.ts", + out: "./lib/db/migrations", + dialect: "postgresql", + dbCredentials: { + // biome-ignore lint: Forbidden non-null assertion. + url: process.env.POSTGRES_URL!, + }, +}); diff --git a/demos/use_cases/vercel-ai-sdk/instrumentation.ts b/demos/use_cases/vercel-ai-sdk/instrumentation.ts new file mode 100644 index 000000000..4b3bdee4b --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/instrumentation.ts @@ -0,0 +1,5 @@ +import { registerOTel } from "@vercel/otel"; + +export function register() { + registerOTel({ serviceName: "ai-chatbot" }); +} diff --git a/demos/use_cases/vercel-ai-sdk/next.config.ts b/demos/use_cases/vercel-ai-sdk/next.config.ts new file mode 100644 index 000000000..1bb1a86fd --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/next.config.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + cacheComponents: true, + images: { + remotePatterns: [ + { + hostname: "avatar.vercel.sh", + }, + { + protocol: "https", + //https://nextjs.org/docs/messages/next-image-unconfigured-host + hostname: "*.public.blob.vercel-storage.com", + }, + ], + }, +}; + +export default nextConfig; diff --git a/demos/use_cases/vercel-ai-sdk/playwright.config.ts b/demos/use_cases/vercel-ai-sdk/playwright.config.ts new file mode 100644 index 000000000..06e998229 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/playwright.config.ts @@ -0,0 +1,100 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import { config } from "dotenv"; + +config({ + path: ".env.local", +}); + +/* Use process.env.PORT by default and fallback to port 3000 */ +const PORT = process.env.PORT || 3000; + +/** + * Set webServer.url and use.baseURL with the location + * of the WebServer respecting the correct set port + */ +const baseURL = `http://localhost:${PORT}`; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Limit workers to prevent browser crashes */ + workers: process.env.CI ? 2 : 2, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "retain-on-failure", + }, + + /* Configure global timeout for each test */ + timeout: 240 * 1000, // 120 seconds + expect: { + timeout: 240 * 1000, + }, + + /* Configure projects */ + projects: [ + { + name: "e2e", + testMatch: /e2e\/.*.test.ts/, + use: { + ...devices["Desktop Chrome"], + }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "pnpm dev", + url: `${baseURL}/ping`, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/demos/use_cases/vercel-ai-sdk/postcss.config.mjs b/demos/use_cases/vercel-ai-sdk/postcss.config.mjs new file mode 100644 index 000000000..79bcf135d --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/demos/use_cases/vercel-ai-sdk/proxy.ts b/demos/use_cases/vercel-ai-sdk/proxy.ts new file mode 100644 index 000000000..ca5a19dda --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/proxy.ts @@ -0,0 +1,59 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { guestRegex, isDevelopmentEnvironment } from "./lib/constants"; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + /* + * Playwright starts the dev server and requires a 200 status to + * begin the tests, so this ensures that the tests can start + */ + if (pathname.startsWith("/ping")) { + return new Response("pong", { status: 200 }); + } + + if (pathname.startsWith("/api/auth")) { + return NextResponse.next(); + } + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (!token) { + const redirectUrl = encodeURIComponent(request.url); + + return NextResponse.redirect( + new URL(`/api/auth/guest?redirectUrl=${redirectUrl}`, request.url) + ); + } + + const isGuest = guestRegex.test(token?.email ?? ""); + + if (token && !isGuest && ["/login", "/register"].includes(pathname)) { + return NextResponse.redirect(new URL("/", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + "/", + "/chat/:id", + "/api/:path*", + "/login", + "/register", + + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", + ], +}; diff --git a/demos/use_cases/vercel-ai-sdk/tsconfig.json b/demos/use_cases/vercel-ai-sdk/tsconfig.json new file mode 100644 index 000000000..e11ae50bc --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "strictNullChecks": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "next.config.js", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/demos/use_cases/vercel-ai-sdk/vercel-template.json b/demos/use_cases/vercel-ai-sdk/vercel-template.json new file mode 100644 index 000000000..08b97ecf9 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/vercel-template.json @@ -0,0 +1,19 @@ +{ + "products": [ + { + "type": "integration", + "protocol": "storage", + "productSlug": "neon", + "integrationSlug": "neon" + }, + { + "type": "integration", + "protocol": "storage", + "productSlug": "upstash-kv", + "integrationSlug": "upstash" + }, + { + "type": "blob" + } + ] +} diff --git a/demos/use_cases/vercel-ai-sdk/vercel.json b/demos/use_cases/vercel-ai-sdk/vercel.json new file mode 100644 index 000000000..f92a3f8a9 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/vercel.json @@ -0,0 +1,3 @@ +{ + "framework": "nextjs" +} From b7af8ab5369872fdae208538e8442c8207c18025 Mon Sep 17 00:00:00 2001 From: Musa Date: Thu, 8 Jan 2026 15:20:47 -0800 Subject: [PATCH 02/11] demo(vercel-ai-sdk): add Next.js app routes, auth, and assets --- .../vercel-ai-sdk/app/(auth)/actions.ts | 84 +++++ .../(auth)/api/auth/[...nextauth]/route.ts | 2 + .../app/(auth)/api/auth/guest/route.ts | 21 ++ .../vercel-ai-sdk/app/(auth)/auth.config.ts | 13 + .../vercel-ai-sdk/app/(auth)/auth.ts | 95 ++++++ .../vercel-ai-sdk/app/(auth)/login/page.tsx | 77 +++++ .../app/(auth)/register/page.tsx | 77 +++++ .../vercel-ai-sdk/app/(chat)/actions.ts | 51 +++ .../app/(chat)/api/chat/[id]/stream/route.ts | 113 +++++++ .../app/(chat)/api/chat/route.ts | 317 +++++++++++++++++ .../app/(chat)/api/chat/schema.ts | 39 +++ .../app/(chat)/api/document/route.ts | 126 +++++++ .../app/(chat)/api/files/upload/route.ts | 68 ++++ .../app/(chat)/api/history/route.ts | 46 +++ .../app/(chat)/api/suggestions/route.ts | 37 ++ .../app/(chat)/api/vote/route.ts | 75 +++++ .../app/(chat)/chat/[id]/page.tsx | 82 +++++ .../vercel-ai-sdk/app/(chat)/layout.tsx | 35 ++ .../app/(chat)/opengraph-image.png | Bin 0 -> 165578 bytes .../vercel-ai-sdk/app/(chat)/page.tsx | 52 +++ .../app/(chat)/twitter-image.png | Bin 0 -> 52049 bytes demos/use_cases/vercel-ai-sdk/app/favicon.ico | Bin 0 -> 15406 bytes demos/use_cases/vercel-ai-sdk/app/globals.css | 318 ++++++++++++++++++ demos/use_cases/vercel-ai-sdk/app/layout.tsx | 87 +++++ .../vercel-ai-sdk/artifacts/actions.ts | 8 + .../vercel-ai-sdk/artifacts/code/client.tsx | 280 +++++++++++++++ .../vercel-ai-sdk/artifacts/code/server.ts | 75 +++++ .../vercel-ai-sdk/artifacts/image/client.tsx | 76 +++++ .../vercel-ai-sdk/artifacts/sheet/client.tsx | 115 +++++++ .../vercel-ai-sdk/artifacts/sheet/server.ts | 81 +++++ .../vercel-ai-sdk/artifacts/text/client.tsx | 179 ++++++++++ .../vercel-ai-sdk/artifacts/text/server.ts | 73 ++++ .../public/images/demo-thumbnail.png | Bin 0 -> 23198 bytes .../images/mouth of the seine, monet.jpg | Bin 0 -> 33497 bytes 34 files changed, 2702 insertions(+) create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/opengraph-image.png create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/twitter-image.png create mode 100644 demos/use_cases/vercel-ai-sdk/app/favicon.ico create mode 100644 demos/use_cases/vercel-ai-sdk/app/globals.css create mode 100644 demos/use_cases/vercel-ai-sdk/app/layout.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/code/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/code/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/image/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/sheet/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/sheet/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/text/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/text/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/public/images/demo-thumbnail.png create mode 100644 demos/use_cases/vercel-ai-sdk/public/images/mouth of the seine, monet.jpg 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/document/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts new file mode 100644 index 000000000..0ea78ff55 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts @@ -0,0 +1,126 @@ +import { auth } from "@/app/(auth)/auth"; +import type { ArtifactKind } from "@/components/artifact"; +import { + deleteDocumentsByIdAfterTimestamp, + getDocumentsById, + saveDocument, +} from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is missing" + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:document").toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (!document) { + return new ChatSDKError("not_found:document").toResponse(); + } + + if (document.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + + return Response.json(documents, { status: 200 }); +} + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("not_found:document").toResponse(); + } + + const { + content, + title, + kind, + }: { content: string; title: string; kind: ArtifactKind } = + await request.json(); + + const documents = await getDocumentsById({ id }); + + if (documents.length > 0) { + const [doc] = documents; + + if (doc.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + } + + const document = await saveDocument({ + id, + content, + title, + kind, + userId: session.user.id, + }); + + return Response.json(document, { status: 200 }); +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const timestamp = searchParams.get("timestamp"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is required." + ).toResponse(); + } + + if (!timestamp) { + return new ChatSDKError( + "bad_request:api", + "Parameter timestamp is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:document").toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (document.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + + const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ + id, + timestamp: new Date(timestamp), + }); + + return Response.json(documentsDeleted, { status: 200 }); +} 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/suggestions/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts new file mode 100644 index 000000000..8801004ef --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts @@ -0,0 +1,37 @@ +import { auth } from "@/app/(auth)/auth"; +import { getSuggestionsByDocumentId } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const documentId = searchParams.get("documentId"); + + if (!documentId) { + return new ChatSDKError( + "bad_request:api", + "Parameter documentId is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:suggestions").toResponse(); + } + + const suggestions = await getSuggestionsByDocumentId({ + documentId, + }); + + const [suggestion] = suggestions; + + if (!suggestion) { + return Response.json([], { status: 200 }); + } + + if (suggestion.userId !== session.user.id) { + return new ChatSDKError("forbidden:api").toResponse(); + } + + return Response.json(suggestions, { 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 ( + <> +