diff --git a/src/app/api/projects/[id]/ai/chat/route.ts b/src/app/api/projects/[id]/ai/chat/route.ts new file mode 100644 index 0000000..4a1a805 --- /dev/null +++ b/src/app/api/projects/[id]/ai/chat/route.ts @@ -0,0 +1,77 @@ +import { projectAction } from "@lib/server-utils"; +import { z } from "zod"; +import { + generateProjectAssistantReply, + type ProjectChatMessage, +} from "@lib/ai/provider"; + +export const dynamic = "force-dynamic"; + +const MAX_BODY_BYTES = 256 * 1024; +const MAX_MESSAGES = 40; +const MAX_MESSAGE_CHARS = 8_000; +const MAX_TOTAL_CHARS = 120_000; + +const messageSchema = z.object({ + role: z.enum(["system", "user", "assistant"]), + content: z.string().trim().min(1).max(MAX_MESSAGE_CHARS), +}); + +const chatSchema = z.object({ + messages: z.array(messageSchema).min(1).max(MAX_MESSAGES), +}); + +export const POST = projectAction( + async (req, { body }) => { + enforceContentLengthLimit(req.headers.get("content-length")); + enforceBodySizeLimit(body); + + const totalChars = body.messages.reduce( + (sum, message) => sum + message.content.length, + 0, + ); + + if (totalChars > MAX_TOTAL_CHARS) { + throw { + status: 413, + message: `Request messages are too large (max ${MAX_TOTAL_CHARS} characters)`, + }; + } + + const providerResult = await generateProjectAssistantReply( + body.messages as ProjectChatMessage[], + ); + + return { + message: providerResult.message, + usage: providerResult.usage, + }; + }, + { + schema: chatSchema, + }, +); + +function enforceContentLengthLimit(contentLength: string | null) { + if (!contentLength) { + return; + } + + const bytes = Number.parseInt(contentLength, 10); + if (!Number.isNaN(bytes) && bytes > MAX_BODY_BYTES) { + throw { + status: 413, + message: `Request body is too large (max ${MAX_BODY_BYTES} bytes)`, + }; + } +} + +function enforceBodySizeLimit(body: z.infer) { + const bytes = Buffer.byteLength(JSON.stringify(body), "utf8"); + if (bytes > MAX_BODY_BYTES) { + throw { + status: 413, + message: `Request body is too large (max ${MAX_BODY_BYTES} bytes)`, + }; + } +} diff --git a/src/app/lib/ai/provider.ts b/src/app/lib/ai/provider.ts new file mode 100644 index 0000000..93d64ff --- /dev/null +++ b/src/app/lib/ai/provider.ts @@ -0,0 +1,106 @@ +import { logger } from "@lib/logger"; + +export type ProjectChatMessage = { + role: "system" | "user" | "assistant"; + content: string; +}; + +export type ProjectChatUsage = { + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + model: string | null; + provider: string; +}; + +export type ProjectChatResult = { + message: ProjectChatMessage; + usage: ProjectChatUsage; +}; + +export async function generateProjectAssistantReply( + messages: ProjectChatMessage[], +): Promise { + const provider = process.env.AI_CHAT_PROVIDER || "openai-compatible"; + + if (provider === "openai-compatible") { + return generateOpenAiCompatibleReply(messages); + } + + throw { status: 500, message: `Unsupported AI provider: ${provider}` }; +} + +async function generateOpenAiCompatibleReply( + messages: ProjectChatMessage[], +): Promise { + const endpoint = process.env.AI_CHAT_ENDPOINT; + const apiKey = process.env.AI_CHAT_API_KEY; + const model = process.env.AI_CHAT_MODEL || "gpt-4o-mini"; + + if (!endpoint || !apiKey) { + throw { + status: 500, + message: "AI provider is not configured", + }; + } + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + logger.warn( + { + status: response.status, + body: text.slice(0, 500), + }, + "AI provider returned non-ok response", + ); + + throw { + status: 502, + message: "AI provider request failed", + }; + } + + const data = (await response.json()) as { + choices?: Array<{ message?: { role?: string; content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + model?: string; + }; + + const assistantContent = data.choices?.[0]?.message?.content?.trim(); + if (!assistantContent) { + throw { + status: 502, + message: "AI provider returned an empty assistant message", + }; + } + + return { + message: { + role: "assistant", + content: assistantContent, + }, + usage: { + inputTokens: data.usage?.prompt_tokens ?? null, + outputTokens: data.usage?.completion_tokens ?? null, + totalTokens: data.usage?.total_tokens ?? null, + model: data.model || model, + provider: "openai-compatible", + }, + }; +}