// AI request + cost logging. A middleware records every AI/render request (user, route, duration, status, // rough est. cost) to Postgres (ai_request_log) or a JSONL file fallback, tracks a rolling daily spend // estimate, and warns when it crosses DAILY_SPEND_ALERT_USD. No prompts/PII are stored here. import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { pgEnabled, pgQuery } from "./db.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const LOG_FILE = path.join(__dirname, "..", ".cache", "ai-usage.jsonl"); try { fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); } catch { /* ignore */ } // coarse $ per call by route (tune from real provider bills). Only billed for requests that actually ran. const COST_PER_CALL = { chat: 0.0015, transcribe: 0.01, script: 0.0015, "generate-scenes": 0.004, "fill-scene": 0.0015, translate: 0.003, analyze: 0.002, export: 0.05, "image-to-scene": 0.004, }; const ROUTE_RE = /^\/api\/(chat\/stream|chat|transcribe-src|transcribe|script|generate-scenes|fill-scene|translate|analyze|export|image-to-scene)\b/; const routeKey = (p) => { if (/^\/api\/projects\/[^/]+\/import\b/.test(p)) return "transcribe"; // base-audio import runs STT const m = p.match(ROUTE_RE); return m ? m[1].replace("/stream", "").replace("-src", "") : null; }; let day = "", daySpend = 0, alerted = false; const ALERT = Number(process.env.DAILY_SPEND_ALERT_USD) || 0; function trackSpend(cost) { const today = new Date().toISOString().slice(0, 10); if (today !== day) { day = today; daySpend = 0; alerted = false; } daySpend += cost; if (ALERT > 0 && daySpend >= ALERT && !alerted) { alerted = true; console.warn(`[cost] ⚠ daily AI spend estimate crossed $${ALERT} — now ~$${daySpend.toFixed(2)}`); } } async function persist(rec) { if (pgEnabled()) { try { await pgQuery( `INSERT INTO ai_request_log (user_id, route, model, in_tokens, out_tokens, est_cost, duration_ms, ok) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, [rec.userId, rec.route, rec.model ?? null, rec.inTokens ?? null, rec.outTokens ?? null, rec.cost, rec.durationMs, rec.ok], ); } catch { /* logging must never break a request */ } } else { try { fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n"); } catch { /* ignore */ } } } export function aiUsageLogger() { return (req, res, next) => { const route = routeKey(req.path); if (!route) return next(); const t0 = Date.now(); res.on("finish", () => { const ok = res.statusCode < 400; const cost = ok ? (COST_PER_CALL[route] ?? 0.002) : 0; // don't bill rejected (402/429/4xx) requests if (cost) trackSpend(cost); persist({ userId: req.userId || null, route, durationMs: Date.now() - t0, ok, cost, status: res.statusCode, at: new Date().toISOString() }); }); next(); }; } export const dailySpend = () => ({ day, usd: +daySpend.toFixed(4) });