nexus/server/src/routes/puter-proxy.ts
Nexus Dev 526acbe8aa feat(31-01): implement puterProxyService, puterProxyRoutes, and unit tests
- puterProxyService with storeToken (create/rotate idempotent), resolveToken, chatStream
- chatStream relays to Puter OpenAI-compat endpoint with SSE streaming
- Cost recording with provider=puter, billingType=subscription_included, costCents=0
- Cost recording skipped when agentId is null/undefined (no FK violation)
- puterProxyRoutes with POST /puter-proxy/token and POST /puter-proxy/chat
- Board auth (assertBoard + assertCompanyAccess) on all routes
- All 10 TDD tests passing
2026-04-04 03:55:49 +00:00

81 lines
2.5 KiB
TypeScript

import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { puterProxyService } from "../services/puter-proxy.js";
import { badRequest } from "../errors.js";
export function puterProxyRoutes(db: Db): Router {
const router = Router();
// POST /puter-proxy/token — store or rotate Puter auth token
router.post("/puter-proxy/token", async (req, res) => {
assertBoard(req);
const { companyId, token } = req.body;
if (!companyId || typeof companyId !== "string") {
throw badRequest("companyId is required");
}
if (!token || typeof token !== "string") {
throw badRequest("token is required");
}
assertCompanyAccess(req, companyId);
await puterProxyService(db).storeToken(companyId, token);
res.json({ ok: true });
});
// POST /puter-proxy/chat — stream AI chat via Puter proxy
router.post("/puter-proxy/chat", async (req, res) => {
assertBoard(req);
const { companyId, agentId, messages, model } = req.body;
if (!companyId || typeof companyId !== "string") {
res.status(400).json({ error: "companyId is required" });
return;
}
if (!Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages must be a non-empty array" });
return;
}
assertCompanyAccess(req, companyId);
// Set SSE headers and flush immediately (same pattern as chat.ts)
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
res.write(":ok\n\n");
const abort = new AbortController();
req.on("close", () => abort.abort());
try {
const svc = puterProxyService(db);
// agentId is optional — pass through as-is (string or undefined)
for await (const chunk of svc.chatStream(
companyId,
agentId as string | undefined,
messages,
model as string | undefined,
abort.signal,
)) {
if (!res.writable) break;
res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);
}
if (res.writable && !abort.signal.aborted) {
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
}
} catch (err) {
if (res.writable) {
res.write(`data: ${JSON.stringify({ error: "Puter stream error" })}\n\n`);
}
} finally {
res.end();
}
});
return router;
}