diff --git a/demos/use_cases/vercel-ai-sdk/.dockerignore b/demos/use_cases/vercel-ai-sdk/.dockerignore new file mode 100644 index 00000000..ec5c2a83 --- /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 00000000..bf62c631 --- /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 00000000..f8a36dbb --- /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 00000000..9065d70b --- /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 00000000..ba27d8fd --- /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 00000000..388ec177 --- /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 00000000..4d033d09 --- /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 00000000..f5912bc3 --- /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 00000000..f782b00a --- /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 00000000..4b3bdee4 --- /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 00000000..1bb1a86f --- /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 00000000..06e99822 --- /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 00000000..79bcf135 --- /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 00000000..ca5a19dd --- /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 00000000..e11ae50b --- /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 00000000..08b97ecf --- /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 00000000..f92a3f8a --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/vercel.json @@ -0,0 +1,3 @@ +{ + "framework": "nextjs" +}