├── 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 | [](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 |
77 | {!hasMessages && (
78 |
79 |
80 |
81 |
82 | AI GATEWAY
83 |
84 |
85 |
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 |
233 | )}
234 |
235 |
257 |
258 | );
259 | }
260 |
--------------------------------------------------------------------------------