- 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
81 lines
2.5 KiB
TypeScript
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;
|
|
}
|