Files
recap-relay/server/routes/analyze.js
T

123 lines
4.1 KiB
JavaScript

// POST /relay/analyze — forwards an analysis prompt to the chosen
// backend and returns the standard envelope.
//
// Request body (application/json):
// { prompt: string }
//
// Headers: same as /relay/transcribe (X-Recap-Install-Id required,
// X-Recap-Job-Id optional, Authorization optional Bearer license).
//
// Same charge-once-per-job semantics: a Recap summarize job pairs
// transcribe + analyze with the same X-Recap-Job-Id. The first call
// (whichever endpoint) charges 1 credit; the second is free.
import express from "express";
import { resolveLicense } from "../keysat-client.js";
import { getOrCreateRow, planBackend, commitCredit } from "../credits.js";
import { lookupJob, markJobCharged, refundJob } from "../job-credits.js";
import { getConfigSnapshot, getTierQuotas } from "../config.js";
import { createGeminiBackend } from "../backends/gemini.js";
import { createHardwareBackend } from "../backends/hardware.js";
import { envelope, errorEnvelope } from "./envelope.js";
export function analyzeRouter() {
const router = express.Router();
router.post("/analyze", express.json({ limit: "10mb" }), async (req, res) => {
const installId = req.header("X-Recap-Install-Id");
const jobId = req.header("X-Recap-Job-Id") || null;
const auth = req.header("Authorization");
if (!installId) {
const e = await errorEnvelope({
error: "missing X-Recap-Install-Id header",
statusHint: 400,
});
return res.status(400).json(e.body);
}
const prompt = req.body?.prompt;
if (!prompt || typeof prompt !== "string") {
const e = await errorEnvelope({
error: "missing or non-string body.prompt",
installId,
statusHint: 400,
});
return res.status(400).json(e.body);
}
const license = await resolveLicense(auth);
const tier = license.tier;
const row = await getOrCreateRow(installId);
row.tier_snapshot = tier;
let reusedJob = false;
let chosenBackend = null;
const existingJob = lookupJob(installId, jobId);
if (existingJob) {
reusedJob = true;
chosenBackend = existingJob.backend;
} else {
const cfg = await getConfigSnapshot();
const hasHardware = !!cfg.relay_gemma_base_url;
const quota = await getTierQuotas();
const preference =
cfg.relay_analyze_backend_preference || "gemini_first";
const plan = planBackend(row, quota, { hasHardware, preference });
if (!plan.allowed) {
const e = await errorEnvelope({
error: plan.reason,
installId,
tier,
statusHint: 402,
});
return res.status(402).json(e.body);
}
chosenBackend = plan.backend;
}
const cfg = await getConfigSnapshot();
let result;
try {
if (chosenBackend === "gemini") {
const backend = createGeminiBackend({
apiKey: cfg.relay_gemini_api_key,
transcriptionModel: cfg.relay_gemini_transcription_model,
analysisModel: cfg.relay_gemini_analysis_model,
});
result = await backend.analyzeText({ prompt });
} else {
const backend = createHardwareBackend({
parakeetBaseURL: cfg.relay_parakeet_base_url,
gemmaBaseURL: cfg.relay_gemma_base_url,
parakeetModel: cfg.relay_parakeet_model,
gemmaModel: cfg.relay_gemma_model,
});
result = await backend.analyzeText({ prompt });
}
} catch (err) {
if (reusedJob) refundJob(installId, jobId);
console.error(`[relay/analyze] backend error: ${err?.message}`);
const e = await errorEnvelope({
error: err?.message || "backend_error",
installId,
tier,
statusHint: err?.status || 502,
});
return res.status(e.statusHint).json(e.body);
}
let creditCharged = 0;
if (!reusedJob) {
await commitCredit(installId, { backend: chosenBackend, tier });
markJobCharged(installId, jobId, { backend: chosenBackend, tier });
creditCharged = 1;
}
const body = await envelope({ result, installId, tier, creditCharged });
res.json(body);
});
return router;
}