Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion app/api/chat/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,47 @@ import { openai } from "@ai-sdk/openai"
import { streamText } from "ai"

import { findRelevantContent, findRelevantRepresentatives, detectQueryTypes } from "@/lib/ai/embedding"
import { NextResponse } from "next/server"
import { z } from "zod"
import { ratelimit, getClientIp } from "@/lib/ratelimit"

export const maxDuration = 30

const chatRequestSchema = z.object({
messages: z
.array(
z
.object({
role: z.enum(["system", "user", "assistant", "data"]),
content: z.string(),
})
.passthrough()
)
.min(1),
})

export async function POST(req: Request) {
const { messages } = await req.json()
// Throttle by client IP. No-op when Upstash Redis env vars are unset, so
// local dev and preview deployments keep working without extra setup.
if (ratelimit) {
const { success } = await ratelimit.limit(getClientIp(req))
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please slow down and try again shortly." },
{ status: 429 }
)
}
}

// Validate the body before use so a malformed payload returns a clean 400
// instead of throwing an unhandled 500 on messages[messages.length - 1].
let messages
try {
const body = await req.json()
messages = chatRequestSchema.parse(body).messages
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 })
}

// Get relevant content for the last message
const lastMessage = messages[messages.length - 1]
Expand Down
32 changes: 32 additions & 0 deletions lib/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"

// Rate limiting uses Upstash Redis (already used elsewhere via QStash). It is
// enabled only when both env vars are present, which keeps local development
// and preview builds working without a Redis instance while still protecting
// production. An in-memory limiter is not an option because each serverless
// invocation runs in isolation and would not share counters.
const url = process.env.UPSTASH_REDIS_REST_URL
const token = process.env.UPSTASH_REDIS_REST_TOKEN

export const ratelimit =
url && token
? new Ratelimit({
redis: new Redis({ url, token }),
// 20 requests per minute per IP, sliding window.
limiter: Ratelimit.slidingWindow(20, "1 m"),
prefix: "numainda:chat",
analytics: true,
})
: null

/**
* Best-effort client IP from proxy headers (Vercel sets x-forwarded-for).
* Falls back to a constant bucket so requests without an IP are still limited
* as a group rather than bypassing the limiter entirely.
*/
export function getClientIp(req: Request): string {
const forwarded = req.headers.get("x-forwarded-for")
if (forwarded) return forwarded.split(",")[0].trim()
return req.headers.get("x-real-ip") ?? "anonymous"
}
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"@supabase/supabase-js": "^2.46.2",
"@t3-oss/env-nextjs": "^0.10.1",
"@upstash/qstash": "^2.7.23",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"@vercel/analytics": "^1.4.1",
"ai": "^4.0.4",
"class-variance-authority": "^0.7.1",
Expand Down