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

218 lines
7.2 KiB
JavaScript

// POST /relay/transcribe — forwards an audio payload to the chosen
// backend (Gemini first, operator hardware as overflow) and returns
// the standard envelope.
//
// Request shape: multipart/form-data
// audio: binary audio file (required)
// mime_type: string (default application/octet-stream)
// title: string (optional, used by Gemini prompt)
// channel: string (optional)
// description: string (optional)
// chapters: JSON-stringified array (optional)
// offset_seconds: number string (optional, for chunked audio)
//
// Headers:
// X-Recap-Install-Id (required)
// X-Recap-Job-Id (optional but expected — pairs with /analyze)
// Authorization (optional Bearer LIC1-... for licensed tiers)
//
// Response (standard envelope):
// {
// result: { text: "[MM:SS] ...", segments: [], duration_seconds: 0 },
// credits_remaining, tier, credit_charged
// }
//
// 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 multer from "multer";
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";
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 200 * 1024 * 1024 }, // 200 MB per request
});
export function transcribeRouter() {
const router = express.Router();
router.post("/transcribe", upload.single("audio"), 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);
}
if (!req.file) {
const e = await errorEnvelope({ error: "missing audio file", 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_parakeet_base_url;
const quota = await getTierQuotas();
const preference =
cfg.relay_transcribe_backend_preference || "gemini_first";
const plan = planBackend(row, quota, { hasHardware, preference });
if (!plan.allowed) {
await recordCall({
install_id: installId,
tier,
pipeline: "transcribe",
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.transcribeAudio({
audio: req.file.buffer,
mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream",
title: req.body?.title || "",
channel: req.body?.channel || "",
description: req.body?.description || "",
chapters: parseChaptersField(req.body?.chapters),
offsetSeconds: Number(req.body?.offset_seconds) || 0,
});
} 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.transcribeAudio({
audio: req.file.buffer,
mimeType: req.body?.mime_type || req.file.mimetype || "application/octet-stream",
offsetSeconds: Number(req.body?.offset_seconds) || 0,
});
}
} catch (err) {
if (reusedJob) refundJob(installId, jobId);
console.error(`[relay/transcribe] backend error: ${err?.message}`);
await recordCall({
install_id: installId,
tier,
pipeline: "transcribe",
backend: chosenBackend,
model: chosenBackend === "gemini"
? cfg.relay_gemini_transcription_model
: cfg.relay_parakeet_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;
}
// Success — write the audit row with cost details. Gemini's usage
// metadata gives us token counts; calcGeminiCost translates that
// into USD. Hardware-served calls have no token data and we
// report cost_usd: 0 (operator's hardware is fixed-cost).
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: "transcribe",
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;
}
function parseChaptersField(raw) {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}