2026-02-13 22:43:55 -08:00
|
|
|
import { z } from "zod";
|
|
|
|
|
import type { FastifyInstance } from "fastify";
|
|
|
|
|
import { prisma } from "./db.js";
|
|
|
|
|
import { requireAdmin } from "./auth.js";
|
2026-02-13 23:15:12 -08:00
|
|
|
import { env } from "./env.js";
|
2026-02-13 22:43:55 -08:00
|
|
|
import { runMultiplex } from "./llm/multiplexer.js";
|
|
|
|
|
import { runMultiplexStream } from "./llm/streaming.js";
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
type IncomingChatMessage = {
|
|
|
|
|
role: "system" | "user" | "assistant" | "tool";
|
|
|
|
|
content: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
|
|
|
|
|
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
|
|
|
|
|
const incoming = messages.filter((m) => m.role !== "assistant");
|
|
|
|
|
if (!incoming.length) return;
|
|
|
|
|
|
|
|
|
|
const existing = await prisma.message.findMany({
|
|
|
|
|
where: { chatId },
|
|
|
|
|
orderBy: { createdAt: "asc" },
|
|
|
|
|
select: { role: true, content: true, name: true },
|
|
|
|
|
});
|
|
|
|
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
|
|
|
|
|
|
|
|
|
|
let sharedPrefix = 0;
|
|
|
|
|
const max = Math.min(existingNonAssistant.length, incoming.length);
|
|
|
|
|
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
|
|
|
|
|
sharedPrefix += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sharedPrefix === incoming.length) return;
|
|
|
|
|
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
|
|
|
|
|
if (!toInsert.length) return;
|
|
|
|
|
|
|
|
|
|
await prisma.message.createMany({
|
|
|
|
|
data: toInsert.map((m) => ({
|
|
|
|
|
chatId,
|
|
|
|
|
role: m.role as any,
|
|
|
|
|
content: m.content,
|
|
|
|
|
name: m.name,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 22:43:55 -08:00
|
|
|
export async function registerRoutes(app: FastifyInstance) {
|
|
|
|
|
app.get("/health", async () => ({ ok: true }));
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
app.get("/v1/auth/session", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
return { authenticated: true, mode: env.ADMIN_TOKEN ? "token" : "open" };
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-13 22:43:55 -08:00
|
|
|
app.get("/v1/chats", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
const chats = await prisma.chat.findMany({
|
|
|
|
|
orderBy: { updatedAt: "desc" },
|
|
|
|
|
take: 100,
|
|
|
|
|
select: { id: true, title: true, createdAt: true, updatedAt: true },
|
|
|
|
|
});
|
|
|
|
|
return { chats };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/v1/chats", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
const Body = z.object({ title: z.string().optional() });
|
|
|
|
|
const body = Body.parse(req.body ?? {});
|
|
|
|
|
const chat = await prisma.chat.create({ data: { title: body.title } });
|
|
|
|
|
return { chat };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get("/v1/chats/:chatId", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
const Params = z.object({ chatId: z.string() });
|
|
|
|
|
const { chatId } = Params.parse(req.params);
|
|
|
|
|
|
|
|
|
|
const chat = await prisma.chat.findUnique({
|
|
|
|
|
where: { id: chatId },
|
|
|
|
|
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
|
|
|
|
});
|
|
|
|
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
|
|
|
|
return { chat };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
const Params = z.object({ chatId: z.string() });
|
|
|
|
|
const Body = z.object({
|
|
|
|
|
role: z.enum(["system", "user", "assistant", "tool"]),
|
|
|
|
|
content: z.string(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
metadata: z.unknown().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { chatId } = Params.parse(req.params);
|
|
|
|
|
const body = Body.parse(req.body);
|
|
|
|
|
|
|
|
|
|
const msg = await prisma.message.create({
|
|
|
|
|
data: {
|
|
|
|
|
chatId,
|
|
|
|
|
role: body.role as any,
|
|
|
|
|
content: body.content,
|
|
|
|
|
name: body.name,
|
|
|
|
|
metadata: body.metadata as any,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { message: msg };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Main: create a completion via provider+model and store everything.
|
|
|
|
|
app.post("/v1/chat-completions", async (req) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
|
|
|
|
|
const Body = z.object({
|
|
|
|
|
chatId: z.string().optional(),
|
|
|
|
|
provider: z.enum(["openai", "anthropic", "xai"]),
|
|
|
|
|
model: z.string().min(1),
|
|
|
|
|
messages: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
role: z.enum(["system", "user", "assistant", "tool"]),
|
|
|
|
|
content: z.string(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
temperature: z.number().min(0).max(2).optional(),
|
|
|
|
|
maxTokens: z.number().int().positive().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const body = Body.parse(req.body);
|
|
|
|
|
|
|
|
|
|
// ensure chat exists if provided
|
|
|
|
|
if (body.chatId) {
|
|
|
|
|
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
|
|
|
|
if (!exists) return app.httpErrors.notFound("chat not found");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
// Store only new non-assistant messages to avoid duplicate history entries.
|
2026-02-13 22:43:55 -08:00
|
|
|
if (body.chatId) {
|
2026-02-13 23:15:12 -08:00
|
|
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
2026-02-13 22:43:55 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await runMultiplex(body);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
chatId: body.chatId ?? null,
|
|
|
|
|
...result,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Streaming SSE endpoint.
|
|
|
|
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
|
|
|
|
requireAdmin(req);
|
|
|
|
|
|
|
|
|
|
const Body = z.object({
|
|
|
|
|
chatId: z.string().optional(),
|
|
|
|
|
provider: z.enum(["openai", "anthropic", "xai"]),
|
|
|
|
|
model: z.string().min(1),
|
|
|
|
|
messages: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
role: z.enum(["system", "user", "assistant", "tool"]),
|
|
|
|
|
content: z.string(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
temperature: z.number().min(0).max(2).optional(),
|
|
|
|
|
maxTokens: z.number().int().positive().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const body = Body.parse(req.body);
|
|
|
|
|
|
|
|
|
|
// ensure chat exists if provided
|
|
|
|
|
if (body.chatId) {
|
|
|
|
|
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
|
|
|
|
if (!exists) return app.httpErrors.notFound("chat not found");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 23:15:12 -08:00
|
|
|
// Store only new non-assistant messages to avoid duplicate history entries.
|
2026-02-13 22:43:55 -08:00
|
|
|
if (body.chatId) {
|
2026-02-13 23:15:12 -08:00
|
|
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
2026-02-13 22:43:55 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reply.raw.writeHead(200, {
|
|
|
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
|
|
|
"Cache-Control": "no-cache, no-transform",
|
|
|
|
|
Connection: "keep-alive",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const send = (event: string, data: any) => {
|
|
|
|
|
reply.raw.write(`event: ${event}\n`);
|
|
|
|
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for await (const ev of runMultiplexStream(body)) {
|
|
|
|
|
if (ev.type === "meta") send("meta", ev);
|
|
|
|
|
else if (ev.type === "delta") send("delta", ev);
|
|
|
|
|
else if (ev.type === "done") send("done", ev);
|
|
|
|
|
else if (ev.type === "error") send("error", ev);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reply.raw.end();
|
|
|
|
|
return reply;
|
|
|
|
|
});
|
|
|
|
|
}
|