├── app ├── favicon.ico ├── page.tsx ├── api │ ├── models │ │ └── route.ts │ └── chat │ │ └── route.ts ├── layout.tsx └── globals.css ├── lib ├── display-model.ts ├── gateway.ts ├── utils.ts ├── constants.ts └── hooks │ └── use-available-models.ts ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── eslint.config.mjs ├── components.json ├── LICENSE ├── .gitignore ├── tsconfig.json ├── components ├── ui │ ├── input.tsx │ ├── alert.tsx │ ├── button.tsx │ ├── card.tsx │ └── select.tsx ├── theme-toggle.tsx ├── model-selector.tsx └── chat.tsx ├── package.json └── README.md /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-gateway-demo/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /lib/display-model.ts: -------------------------------------------------------------------------------- 1 | export interface DisplayModel { 2 | id: string; 3 | label: string; 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /lib/gateway.ts: -------------------------------------------------------------------------------- 1 | import { createGatewayProvider } from "@ai-sdk/gateway"; 2 | 3 | export const gateway = createGatewayProvider({ 4 | baseURL: process.env.AI_GATEWAY_BASE_URL, 5 | }); 6 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chat"; 2 | 3 | export default async function Page({ 4 | searchParams, 5 | }: { 6 | searchParams: Promise<{ modelId: string }>; 7 | }) { 8 | const { modelId } = await searchParams; 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_MODEL = "xai/grok-3"; 2 | 3 | export const SUPPORTED_MODELS = [ 4 | "amazon/nova-lite", 5 | "amazon/nova-micro", 6 | "anthropic/claude-3.5-haiku", 7 | "google/gemini-2.0-flash", 8 | "google/gemma2-9b-it", 9 | "meta/llama-3.1-8b", 10 | "mistral/ministral-3b", 11 | "openai/gpt-3.5-turbo", 12 | "openai/gpt-4o-mini", 13 | "xai/grok-3", 14 | ]; 15 | -------------------------------------------------------------------------------- /app/api/models/route.ts: -------------------------------------------------------------------------------- 1 | import { gateway } from "@/lib/gateway"; 2 | import { NextResponse } from "next/server"; 3 | import { SUPPORTED_MODELS } from "@/lib/constants"; 4 | 5 | export async function GET() { 6 | const allModels = await gateway.getAvailableModels(); 7 | return NextResponse.json({ 8 | models: allModels.models.filter((model) => 9 | SUPPORTED_MODELS.includes(model.id) 10 | ), 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Vercel, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | .env*.local 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { convertToModelMessages, streamText, type UIMessage } from "ai"; 2 | import { DEFAULT_MODEL, SUPPORTED_MODELS } from "@/lib/constants"; 3 | import { gateway } from "@/lib/gateway"; 4 | 5 | export const maxDuration = 60; 6 | 7 | export async function POST(req: Request) { 8 | const { 9 | messages, 10 | modelId = DEFAULT_MODEL, 11 | }: { messages: UIMessage[]; modelId: string } = await req.json(); 12 | 13 | if (!SUPPORTED_MODELS.includes(modelId)) { 14 | return new Response( 15 | JSON.stringify({ error: `Model ${modelId} is not supported` }), 16 | { status: 400, headers: { "Content-Type": "application/json" } } 17 | ); 18 | } 19 | 20 | const result = streamText({ 21 | model: gateway(modelId), 22 | system: "You are a software engineer exploring Generative AI.", 23 | messages: convertToModelMessages(messages), 24 | onError: (e) => { 25 | console.error("Error while streaming.", e); 26 | }, 27 | }); 28 | 29 | return result.toUIMessageStreamResponse(); 30 | } 31 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import { ThemeProvider } from "next-themes"; 4 | import "./globals.css"; 5 | 6 | const geistSans = Geist({ 7 | variable: "--font-geist-sans", 8 | subsets: ["latin"], 9 | }); 10 | 11 | const geistMono = Geist_Mono({ 12 | variable: "--font-geist-mono", 13 | subsets: ["latin"], 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "AI Gateway Demo", 18 | description: "A demo of the Vercel AI Gateway with the AI SDK by Vercel", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 31 | 37 | {children} 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Moon, Sun } from "lucide-react"; 6 | import { useEffect, useState } from "react"; 7 | 8 | export function ThemeToggle() { 9 | const { theme, setTheme } = useTheme(); 10 | const [mounted, setMounted] = useState(false); 11 | 12 | useEffect(() => { 13 | setMounted(true); 14 | }, []); 15 | 16 | if (!mounted) { 17 | return ( 18 | 25 | ); 26 | } 27 | 28 | return ( 29 | 41 | ); 42 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-sdk-gateway-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "type-check": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@ai-sdk/gateway": "1.0.15", 14 | "@ai-sdk/react": "2.0.28", 15 | "@radix-ui/react-select": "^2.2.2", 16 | "@radix-ui/react-slot": "^1.2.0", 17 | "ai": "5.0.28", 18 | "class-variance-authority": "^0.7.1", 19 | "clsx": "^2.1.1", 20 | "lucide-react": "^0.506.0", 21 | "next": "15.3.8", 22 | "next-themes": "^0.4.6", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0", 25 | "streamdown": "^1.6.8", 26 | "tailwind-merge": "^3.2.0", 27 | "tw-animate-css": "^1.2.8", 28 | "zod": "^3.25.46" 29 | }, 30 | "devDependencies": { 31 | "@eslint/eslintrc": "^3.3.1", 32 | "@tailwindcss/postcss": "^4.1.5", 33 | "@types/node": "^22.15.3", 34 | "@types/react": "^19.1.2", 35 | "@types/react-dom": "^19.1.3", 36 | "eslint": "^9.25.1", 37 | "eslint-config-next": "15.3.6", 38 | "tailwindcss": "^4.1.5", 39 | "typescript": "^5.8.3" 40 | }, 41 | "packageManager": "pnpm@10.6.2+sha512.47870716bea1572b53df34ad8647b42962bc790ce2bf4562ba0f643237d7302a3d6a8ecef9e4bdfc01d23af1969aa90485d4cebb0b9638fa5ef1daef656f6c1b" 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple [Next.js](https://nextjs.org) chatbot app to demonstrate the use of the Vercel AI Gateway with the [AI SDK](https://sdk.vercel.ai). 2 | 3 | ## Getting Started 4 | 5 | ### One-time setup 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-gateway-demo) 8 | 9 | 1. Clone this repository with the Deploy button above 10 | 1. Install the [Vercel CLI](https://vercel.com/docs/cli) if you don't already have it 11 | 1. Clone the repository you created above: `git clone ` 12 | 1. Link it to a Vercel project: `vc link` or `vc deploy` 13 | 14 | ### Usage 15 | 1. Install packages with `pnpm i` (or `npm i` or `yarn i`) and run the development server with `vc dev` 16 | 1. Open http://localhost:3000 to try the chatbot 17 | 18 | ### FAQ 19 | 20 | 1. If you prefer running your local development server directly rather than using `vc dev`, you'll need to run `vc env pull` to fetch the project's OIDC authentication token locally 21 | 1. the token expires every 12h, so you'll need to re-run this command periodically. 22 | 1. if you use `vc dev` it will auto-refresh the token for you, so you don't need to fetch it manually 23 | 1. If you're linking to an existing, older project, you may need to enable the OIDC token feature in your project settings. 24 | 1. visit the project settings page (rightmost tab in your project's dashboard) 25 | 1. search for 'OIDC' in settings 26 | 1. toggle the button under "Secure Backend Access with OIDC Federation" to Enabled and click the "Save" button 27 | 28 | ## Authors 29 | 30 | This repository is maintained by the [Vercel](https://vercel.com) team and community contributors. 31 | 32 | Contributions are welcome! Feel free to open issues or submit pull requests to enhance functionality or fix bugs. 33 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-card text-card-foreground", 12 | destructive: 13 | "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /lib/hooks/use-available-models.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import type { DisplayModel } from "@/lib/display-model"; 3 | import type { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; 4 | import { SUPPORTED_MODELS } from "@/lib/constants"; 5 | 6 | const MAX_RETRIES = 3; 7 | const RETRY_DELAY_MILLIS = 5000; 8 | 9 | function buildModelList(models: GatewayLanguageModelEntry[]): DisplayModel[] { 10 | return models 11 | .filter((model) => SUPPORTED_MODELS.includes(model.id)) 12 | .map((model) => ({ 13 | id: model.id, 14 | label: model.name, 15 | })); 16 | } 17 | 18 | export function useAvailableModels() { 19 | const [models, setModels] = useState([]); 20 | const [isLoading, setIsLoading] = useState(true); 21 | const [error, setError] = useState(null); 22 | const [retryCount, setRetryCount] = useState(0); 23 | 24 | const fetchModels = useCallback( 25 | async (isRetry: boolean = false) => { 26 | if (!isRetry) { 27 | setIsLoading(true); 28 | setError(null); 29 | } 30 | 31 | try { 32 | const response = await fetch("/api/models"); 33 | if (!response.ok) { 34 | throw new Error("Failed to fetch models"); 35 | } 36 | const data = await response.json(); 37 | const newModels = buildModelList(data.models); 38 | setModels(newModels); 39 | setError(null); 40 | setRetryCount(0); 41 | setIsLoading(false); 42 | } catch (err) { 43 | setError( 44 | err instanceof Error ? err : new Error("Failed to fetch models") 45 | ); 46 | if (retryCount < MAX_RETRIES) { 47 | setRetryCount((prev) => prev + 1); 48 | setIsLoading(true); 49 | } else { 50 | setIsLoading(false); 51 | } 52 | } finally { 53 | setIsLoading(false); 54 | } 55 | }, 56 | [retryCount] 57 | ); 58 | 59 | useEffect(() => { 60 | if (retryCount === 0) { 61 | fetchModels(false); 62 | } else if (retryCount > 0 && retryCount <= MAX_RETRIES) { 63 | const timerId = setTimeout(() => { 64 | fetchModels(true); 65 | }, RETRY_DELAY_MILLIS); 66 | return () => clearTimeout(timerId); 67 | } 68 | }, [retryCount, fetchModels]); 69 | 70 | return { models, isLoading, error }; 71 | } 72 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /components/model-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAvailableModels } from "@/lib/hooks/use-available-models"; 4 | import { Loader2, ChevronDown } from "lucide-react"; 5 | import { DEFAULT_MODEL } from "@/lib/constants"; 6 | import { 7 | Select, 8 | SelectContent, 9 | SelectItem, 10 | SelectTrigger, 11 | SelectValue, 12 | SelectGroup, 13 | SelectLabel, 14 | } from "@/components/ui/select"; 15 | import { memo } from "react"; 16 | 17 | type ModelSelectorProps = { 18 | modelId: string; 19 | onModelChange: (modelId: string) => void; 20 | }; 21 | 22 | export const ModelSelector = memo(function ModelSelector({ 23 | modelId = DEFAULT_MODEL, 24 | onModelChange, 25 | }: ModelSelectorProps) { 26 | const { models, isLoading, error } = useAvailableModels(); 27 | 28 | return ( 29 | 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Select({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function SelectGroup({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return 19 | } 20 | 21 | function SelectValue({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return 25 | } 26 | 27 | function SelectTrigger({ 28 | className, 29 | size = "default", 30 | children, 31 | ...props 32 | }: React.ComponentProps & { 33 | size?: "sm" | "default" 34 | }) { 35 | return ( 36 | 45 | {children} 46 | 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | function SelectContent({ 54 | className, 55 | children, 56 | position = "popper", 57 | ...props 58 | }: React.ComponentProps) { 59 | return ( 60 | 61 | 72 | 73 | 80 | {children} 81 | 82 | 83 | 84 | 85 | ) 86 | } 87 | 88 | function SelectLabel({ 89 | className, 90 | ...props 91 | }: React.ComponentProps) { 92 | return ( 93 | 98 | ) 99 | } 100 | 101 | function SelectItem({ 102 | className, 103 | children, 104 | ...props 105 | }: React.ComponentProps) { 106 | return ( 107 | 115 | 116 | 117 | 118 | 119 | 120 | {children} 121 | 122 | ) 123 | } 124 | 125 | function SelectSeparator({ 126 | className, 127 | ...props 128 | }: React.ComponentProps) { 129 | return ( 130 | 135 | ) 136 | } 137 | 138 | function SelectScrollUpButton({ 139 | className, 140 | ...props 141 | }: React.ComponentProps) { 142 | return ( 143 | 151 | 152 | 153 | ) 154 | } 155 | 156 | function SelectScrollDownButton({ 157 | className, 158 | ...props 159 | }: React.ComponentProps) { 160 | return ( 161 | 169 | 170 | 171 | ) 172 | } 173 | 174 | export { 175 | Select, 176 | SelectContent, 177 | SelectGroup, 178 | SelectItem, 179 | SelectLabel, 180 | SelectScrollDownButton, 181 | SelectScrollUpButton, 182 | SelectSeparator, 183 | SelectTrigger, 184 | SelectValue, 185 | } 186 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | @source "../node_modules/streamdown/dist/*.js"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme inline { 8 | --color-background: var(--background); 9 | --color-foreground: var(--foreground); 10 | --font-sans: var(--font-geist-sans); 11 | --font-mono: var(--font-geist-mono); 12 | --color-sidebar-ring: var(--sidebar-ring); 13 | --color-sidebar-border: var(--sidebar-border); 14 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 15 | --color-sidebar-accent: var(--sidebar-accent); 16 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 17 | --color-sidebar-primary: var(--sidebar-primary); 18 | --color-sidebar-foreground: var(--sidebar-foreground); 19 | --color-sidebar: var(--sidebar); 20 | --color-chart-5: var(--chart-5); 21 | --color-chart-4: var(--chart-4); 22 | --color-chart-3: var(--chart-3); 23 | --color-chart-2: var(--chart-2); 24 | --color-chart-1: var(--chart-1); 25 | --color-ring: var(--ring); 26 | --color-input: var(--input); 27 | --color-border: var(--border); 28 | --color-destructive: var(--destructive); 29 | --color-accent-foreground: var(--accent-foreground); 30 | --color-accent: var(--accent); 31 | --color-muted-foreground: var(--muted-foreground); 32 | --color-muted: var(--muted); 33 | --color-secondary-foreground: var(--secondary-foreground); 34 | --color-secondary: var(--secondary); 35 | --color-primary-foreground: var(--primary-foreground); 36 | --color-primary: var(--primary); 37 | --color-popover-foreground: var(--popover-foreground); 38 | --color-popover: var(--popover); 39 | --color-card-foreground: var(--card-foreground); 40 | --color-card: var(--card); 41 | --radius-sm: calc(var(--radius) - 4px); 42 | --radius-md: calc(var(--radius) - 2px); 43 | --radius-lg: var(--radius); 44 | --radius-xl: calc(var(--radius) + 4px); 45 | } 46 | 47 | :root { 48 | --radius: 0.75rem; 49 | --background: oklch(0.99 0 0); 50 | --foreground: oklch(0.12 0 0); 51 | --card: oklch(1 0 0); 52 | --card-foreground: oklch(0.12 0 0); 53 | --popover: oklch(1 0 0); 54 | --popover-foreground: oklch(0.12 0 0); 55 | --primary: oklch(0.15 0 0); 56 | --primary-foreground: oklch(0.98 0 0); 57 | --secondary: oklch(0.96 0 0); 58 | --secondary-foreground: oklch(0.15 0 0); 59 | --muted: oklch(0.96 0 0); 60 | --muted-foreground: oklch(0.45 0 0); 61 | --accent: oklch(0.94 0 0); 62 | --accent-foreground: oklch(0.15 0 0); 63 | --destructive: oklch(0.577 0.245 27.325); 64 | --border: oklch(0.89 0 0); 65 | --input: oklch(0.94 0 0); 66 | --ring: oklch(0.15 0 0); 67 | --chart-1: oklch(0.646 0.222 41.116); 68 | --chart-2: oklch(0.6 0.118 184.704); 69 | --chart-3: oklch(0.398 0.07 227.392); 70 | --chart-4: oklch(0.828 0.189 84.429); 71 | --chart-5: oklch(0.769 0.188 70.08); 72 | --sidebar: oklch(0.98 0 0); 73 | --sidebar-foreground: oklch(0.12 0 0); 74 | --sidebar-primary: oklch(0.15 0 0); 75 | --sidebar-primary-foreground: oklch(0.98 0 0); 76 | --sidebar-accent: oklch(0.96 0 0); 77 | --sidebar-accent-foreground: oklch(0.15 0 0); 78 | --sidebar-border: oklch(0.89 0 0); 79 | --sidebar-ring: oklch(0.15 0 0); 80 | } 81 | 82 | .dark { 83 | --background: oklch(0.08 0 0); 84 | --foreground: oklch(0.95 0 0); 85 | --card: oklch(0.12 0 0); 86 | --card-foreground: oklch(0.95 0 0); 87 | --popover: oklch(0.12 0 0); 88 | --popover-foreground: oklch(0.95 0 0); 89 | --primary: oklch(0.88 0 0); 90 | --primary-foreground: oklch(0.08 0 0); 91 | --secondary: oklch(0.18 0 0); 92 | --secondary-foreground: oklch(0.95 0 0); 93 | --muted: oklch(0.18 0 0); 94 | --muted-foreground: oklch(0.65 0 0); 95 | --accent: oklch(0.18 0 0); 96 | --accent-foreground: oklch(0.95 0 0); 97 | --destructive: oklch(0.704 0.191 22.216); 98 | --border: oklch(0.95 0 0 / 12%); 99 | --input: oklch(0.95 0 0 / 8%); 100 | --ring: oklch(0.88 0 0); 101 | --chart-1: oklch(0.488 0.243 264.376); 102 | --chart-2: oklch(0.696 0.17 162.48); 103 | --chart-3: oklch(0.769 0.188 70.08); 104 | --chart-4: oklch(0.627 0.265 303.9); 105 | --chart-5: oklch(0.645 0.246 16.439); 106 | --sidebar: oklch(0.12 0 0); 107 | --sidebar-foreground: oklch(0.95 0 0); 108 | --sidebar-primary: oklch(0.88 0 0); 109 | --sidebar-primary-foreground: oklch(0.08 0 0); 110 | --sidebar-accent: oklch(0.18 0 0); 111 | --sidebar-accent-foreground: oklch(0.95 0 0); 112 | --sidebar-border: oklch(0.95 0 0 / 12%); 113 | --sidebar-ring: oklch(0.88 0 0); 114 | } 115 | 116 | @layer base { 117 | * { 118 | @apply border-border outline-ring/50; 119 | } 120 | body { 121 | @apply bg-background text-foreground; 122 | } 123 | 124 | /* Hide scrollbar while keeping functionality */ 125 | .hide-scrollbar { 126 | scrollbar-width: none; /* Firefox */ 127 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 128 | } 129 | 130 | .hide-scrollbar::-webkit-scrollbar { 131 | display: none; /* Safari and Chrome */ 132 | } 133 | } 134 | 135 | @layer utilities { 136 | .animate-fade-in { 137 | animation: fade-in 200ms ease-out; 138 | } 139 | 140 | .animate-slide-up { 141 | animation: slide-up 200ms ease-out; 142 | } 143 | 144 | .animate-slide-down { 145 | animation: slide-down 150ms ease-out; 146 | } 147 | 148 | .animate-scale-in { 149 | animation: scale-in 200ms ease-out; 150 | } 151 | 152 | .animate-message-in { 153 | animation: message-in 300ms ease-out; 154 | } 155 | 156 | .shadow-border-small { 157 | box-shadow: 158 | 0 0 0 1px rgba(0, 0, 0, 0.08), 159 | 0 1px 2px rgba(0, 0, 0, 0.12); 160 | } 161 | 162 | .shadow-border-medium { 163 | box-shadow: 164 | 0 0 0 1px rgba(0, 0, 0, 0.08), 165 | 0 2px 4px rgba(0, 0, 0, 0.12), 166 | 0 1px 6px rgba(0, 0, 0, 0.05); 167 | } 168 | 169 | .dark .shadow-border-small { 170 | box-shadow: 171 | 0 0 0 1px rgba(255, 255, 255, 0.1), 172 | 0 1px 2px rgba(0, 0, 0, 0.4); 173 | } 174 | 175 | .dark .shadow-border-medium { 176 | box-shadow: 177 | 0 0 0 1px rgba(255, 255, 255, 0.1), 178 | 0 2px 4px rgba(0, 0, 0, 0.4), 179 | 0 1px 6px rgba(0, 0, 0, 0.2); 180 | } 181 | 182 | .glass-effect { 183 | backdrop-filter: blur(12px); 184 | background: rgba(255, 255, 255, 0.8); 185 | border: 1px solid rgba(255, 255, 255, 0.2); 186 | } 187 | 188 | .dark .glass-effect { 189 | background: rgba(0, 0, 0, 0.4); 190 | border: 1px solid rgba(255, 255, 255, 0.1); 191 | } 192 | 193 | @keyframes fade-in { 194 | from { 195 | opacity: 0; 196 | } 197 | to { 198 | opacity: 1; 199 | } 200 | } 201 | 202 | @keyframes slide-up { 203 | from { 204 | opacity: 0; 205 | transform: translateY(20px); 206 | } 207 | to { 208 | opacity: 1; 209 | transform: translateY(0); 210 | } 211 | } 212 | 213 | @keyframes slide-down { 214 | from { 215 | opacity: 0; 216 | transform: translateY(-10px); 217 | } 218 | to { 219 | opacity: 1; 220 | transform: translateY(0); 221 | } 222 | } 223 | 224 | @keyframes scale-in { 225 | from { 226 | opacity: 0; 227 | transform: scale(0.95); 228 | } 229 | to { 230 | opacity: 1; 231 | transform: scale(1); 232 | } 233 | } 234 | 235 | @keyframes message-in { 236 | from { 237 | opacity: 0; 238 | transform: translateY(20px); 239 | filter: blur(4px); 240 | } 241 | to { 242 | opacity: 1; 243 | transform: translateY(0); 244 | filter: blur(0); 245 | } 246 | } 247 | 248 | @media (prefers-reduced-motion: reduce) { 249 | .animate-fade-in, 250 | .animate-slide-up, 251 | .animate-slide-down, 252 | .animate-scale-in, 253 | .animate-message-in { 254 | animation: fade-in 200ms ease-out; 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useChat } from "@ai-sdk/react"; 4 | import { useRouter } from "next/navigation"; 5 | import { ModelSelector } from "@/components/model-selector"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | import { ThemeToggle } from "@/components/theme-toggle"; 9 | import { SendIcon, PlusIcon } from "lucide-react"; 10 | import { useState, useEffect, useRef } from "react"; 11 | import { DEFAULT_MODEL } from "@/lib/constants"; 12 | import { Alert, AlertDescription } from "@/components/ui/alert"; 13 | import { AlertCircle } from "lucide-react"; 14 | import { cn } from "@/lib/utils"; 15 | import Link from "next/link"; 16 | import { Streamdown } from "streamdown"; 17 | 18 | function ModelSelectorHandler({ 19 | modelId, 20 | onModelIdChange, 21 | }: { 22 | modelId: string; 23 | onModelIdChange: (newModelId: string) => void; 24 | }) { 25 | const router = useRouter(); 26 | 27 | const handleSelectChange = (newModelId: string) => { 28 | onModelIdChange(newModelId); 29 | const params = new URLSearchParams(); 30 | params.set("modelId", newModelId); 31 | router.push(`?${params.toString()}`); 32 | }; 33 | 34 | return ; 35 | } 36 | 37 | export function Chat({ modelId = DEFAULT_MODEL }: { modelId: string }) { 38 | const [input, setInput] = useState(""); 39 | const [currentModelId, setCurrentModelId] = useState(modelId); 40 | const messagesEndRef = useRef(null); 41 | 42 | const handleModelIdChange = (newModelId: string) => { 43 | setCurrentModelId(newModelId); 44 | }; 45 | 46 | const { messages, error, sendMessage, regenerate, setMessages, stop, status } = useChat(); 47 | 48 | const hasMessages = messages.length > 0; 49 | 50 | const scrollToBottom = () => { 51 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 52 | }; 53 | 54 | useEffect(() => { 55 | scrollToBottom(); 56 | }, [messages]); 57 | 58 | const handleNewChat = () => { 59 | stop(); 60 | setMessages([]); 61 | setInput(""); 62 | }; 63 | 64 | return ( 65 |
66 |
67 | 75 | 76 |
77 | {!hasMessages && ( 78 |
79 |
80 |

81 | 82 | AI GATEWAY 83 | 84 |

85 |
86 |
{ 88 | e.preventDefault(); 89 | sendMessage({ text: input }, { body: { modelId: currentModelId } }); 90 | setInput(""); 91 | }} 92 | > 93 |
94 | 98 |
99 | setInput(e.target.value)} 103 | value={input} 104 | autoFocus 105 | className="flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-base placeholder:text-muted-foreground/60" 106 | onKeyDown={(e) => { 107 | if (e.metaKey && e.key === "Enter") { 108 | sendMessage( 109 | { text: input }, 110 | { body: { modelId: currentModelId } }, 111 | ); 112 | setInput(""); 113 | } 114 | }} 115 | /> 116 | 125 |
126 |
127 |
128 |
129 |
130 |
131 | )} 132 | 133 | {hasMessages && ( 134 |
135 |
136 |
137 | {messages.map((m) => ( 138 |
146 | {m.parts.map((part, i) => { 147 | switch (part.type) { 148 | case "text": 149 | return m.role === "assistant" ? ( 150 | 151 | {part.text} 152 | 153 | ) : ( 154 |
{part.text}
155 | ); 156 | } 157 | })} 158 |
159 | ))} 160 | 161 |
162 |
163 |
164 |
165 | )} 166 | 167 | {error && ( 168 |
169 | 170 |
171 | 172 | 173 | {error.message.startsWith("AI Gateway requires a valid credit card") ?
AI Gateway requires a valid credit card on file to service requests. Please visit your dashboard to add a card and unlock your free credits.
: "An error occurred while generating the response."} 174 |
175 |
176 | 184 |
185 |
186 | )} 187 | 188 | {hasMessages && ( 189 |
190 |
{ 192 | e.preventDefault(); 193 | sendMessage({ text: input }, { body: { modelId: currentModelId } }); 194 | setInput(""); 195 | }} 196 | className="px-4 md:px-8 pb-6 md:pb-8" 197 | > 198 |
199 | 203 |
204 | setInput(e.target.value)} 208 | value={input} 209 | className="flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-base placeholder:text-muted-foreground/60 font-medium" 210 | onKeyDown={(e) => { 211 | if (e.metaKey && e.key === "Enter") { 212 | sendMessage( 213 | { text: input }, 214 | { body: { modelId: currentModelId } }, 215 | ); 216 | setInput(""); 217 | } 218 | }} 219 | /> 220 | 229 |
230 |
231 |
232 |
233 | )} 234 | 235 | 257 |
258 | ); 259 | } 260 | --------------------------------------------------------------------------------