180 lines
5.7 KiB
JavaScript
180 lines
5.7 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.
|
|
//
|
|
// Every outcome (success / quota-refused / backend-error) writes one
|
|
// row to the audit log so the admin dashboard can compute cost,
|
|
// margin, and speed metrics.
|
|
|
|
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";
|
|
import { recordCall } from "../audit-log.js";
|
|
import { calcGeminiCost } from "../pricing.js";
|
|
|
|
export function analyzeRouter() {
|
|
const router = express.Router();
|
|
|
|
router.post("/analyze", express.json({ limit: "10mb" }), async (req, res) => {
|
|
const t0 = Date.now();
|
|
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) {
|
|
await recordCall({
|
|
install_id: installId,
|
|
tier,
|
|
pipeline: "analyze",
|
|
backend: null,
|
|
model: null,
|
|
status: "refused",
|
|
credit_charged: 0,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0,
|
|
job_id: jobId,
|
|
error: plan.reason,
|
|
});
|
|
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}`);
|
|
await recordCall({
|
|
install_id: installId,
|
|
tier,
|
|
pipeline: "analyze",
|
|
backend: chosenBackend,
|
|
model: chosenBackend === "gemini"
|
|
? cfg.relay_gemini_analysis_model
|
|
: cfg.relay_gemma_model,
|
|
status: "error",
|
|
credit_charged: 0,
|
|
duration_ms: Date.now() - t0,
|
|
cost_usd: 0,
|
|
job_id: jobId,
|
|
error: (err?.message || String(err)).slice(0, 200),
|
|
});
|
|
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 costDetails =
|
|
chosenBackend === "gemini" && result.usage
|
|
? calcGeminiCost(result.model, result.usage)
|
|
: {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
thinking_tokens: 0,
|
|
cost_usd: 0,
|
|
};
|
|
await recordCall({
|
|
install_id: installId,
|
|
tier,
|
|
pipeline: "analyze",
|
|
backend: chosenBackend,
|
|
model: result?.model || null,
|
|
status: "success",
|
|
credit_charged: creditCharged,
|
|
duration_ms: Date.now() - t0,
|
|
job_id: jobId,
|
|
...costDetails,
|
|
});
|
|
|
|
const body = await envelope({ result, installId, tier, creditCharged });
|
|
res.json(body);
|
|
});
|
|
|
|
return router;
|
|
}
|