initial relay scaffold
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
// /admin/* — operator dashboard endpoints. All require the admin
|
||||
// session cookie (enforced by admin-auth middleware).
|
||||
//
|
||||
// v0.1 endpoints (JSON only; v0.2 will add an HTML dashboard):
|
||||
// GET /admin/usage — all install rows + last-month aggregates
|
||||
// GET /admin/config — current operator config (sans password hash)
|
||||
// POST /admin/quotas — adjust tier quotas live (mirror of StartOS
|
||||
// action but reachable from the dashboard)
|
||||
|
||||
import express from "express";
|
||||
import { getConfigSnapshot } from "../config.js";
|
||||
import { snapshotAll } from "../credits.js";
|
||||
import { snapshotCache } from "../keysat-client.js";
|
||||
import { snapshotJobs } from "../job-credits.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export function adminRouter({ dataDir }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/usage", async (_req, res) => {
|
||||
const rows = snapshotAll();
|
||||
res.json({
|
||||
installs: rows.length,
|
||||
rows,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/config", async (_req, res) => {
|
||||
const cfg = await getConfigSnapshot();
|
||||
// Strip secrets before exposing to the dashboard.
|
||||
const safe = {
|
||||
keysat_base_url: cfg.relay_keysat_base_url,
|
||||
parakeet_base_url: cfg.relay_parakeet_base_url,
|
||||
gemma_base_url: cfg.relay_gemma_base_url,
|
||||
gemini_configured: !!cfg.relay_gemini_api_key,
|
||||
admin_username: cfg.relay_admin_username,
|
||||
tier_quotas: tryParse(cfg.relay_tier_quotas_json),
|
||||
};
|
||||
res.json(safe);
|
||||
});
|
||||
|
||||
router.get("/license-cache", async (_req, res) => {
|
||||
res.json({ entries: snapshotCache() });
|
||||
});
|
||||
|
||||
router.get("/jobs", async (_req, res) => {
|
||||
res.json({ entries: snapshotJobs() });
|
||||
});
|
||||
|
||||
// Adjust the live quotas blob. Same shape the StartOS action writes
|
||||
// to relay_tier_quotas_json — kept here so the dashboard can tune
|
||||
// quotas without round-tripping the StartOS UI.
|
||||
router.post("/quotas", express.json(), async (req, res) => {
|
||||
const incoming = req.body || {};
|
||||
const normalized = {
|
||||
core: {
|
||||
lifetime: numOrNull(incoming?.core?.lifetime, 5),
|
||||
monthly: numOrNull(incoming?.core?.monthly, null),
|
||||
geminiCapMonthly: numOrNull(incoming?.core?.geminiCapMonthly, null),
|
||||
},
|
||||
pro: {
|
||||
lifetime: numOrNull(incoming?.pro?.lifetime, null),
|
||||
monthly: numOrNull(incoming?.pro?.monthly, 50),
|
||||
geminiCapMonthly: numOrNull(incoming?.pro?.geminiCapMonthly, 25),
|
||||
},
|
||||
max: {
|
||||
lifetime: numOrNull(incoming?.max?.lifetime, null),
|
||||
monthly: numOrNull(incoming?.max?.monthly, null),
|
||||
geminiCapMonthly: numOrNull(incoming?.max?.geminiCapMonthly, 50),
|
||||
},
|
||||
};
|
||||
// Write directly into relay-config.json — the live-reloader picks
|
||||
// it up on the next read.
|
||||
const configPath = path.join(dataDir, "config", "relay-config.json");
|
||||
let existing = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
} catch {}
|
||||
existing.relay_tier_quotas_json = JSON.stringify(normalized);
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 });
|
||||
res.json({ ok: true, quotas: normalized });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function numOrNull(v, fallback) {
|
||||
if (v === null) return null;
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) return n;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function tryParse(s) {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 plan = planBackend(row, quota, { hasHardware });
|
||||
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 });
|
||||
result = await backend.analyzeText({ prompt });
|
||||
} else {
|
||||
const backend = createHardwareBackend({
|
||||
parakeetBaseURL: cfg.relay_parakeet_base_url,
|
||||
gemmaBaseURL: cfg.relay_gemma_base_url,
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Standard response envelope. Every /relay/* response (success and
|
||||
// error both) goes through this so Recap clients can keep their
|
||||
// credit-balance display accurate regardless of what happened.
|
||||
//
|
||||
// Shape: { result, credits_remaining, tier, credit_charged }
|
||||
|
||||
import { getOrCreateRow, computeRemaining } from "../credits.js";
|
||||
import { getTierQuotas } from "../config.js";
|
||||
|
||||
// Build the envelope around a result object.
|
||||
export async function envelope({
|
||||
result = null,
|
||||
installId,
|
||||
tier,
|
||||
creditCharged = 0,
|
||||
}) {
|
||||
const quota = await getTierQuotas();
|
||||
const row = await getOrCreateRow(installId);
|
||||
// tier_snapshot on the row was just updated by commitCredit; if no
|
||||
// credit was committed (free reuse via job_id) it still reflects
|
||||
// the last-known tier for this install, which is fine.
|
||||
const balance = computeRemaining(row, quota);
|
||||
return {
|
||||
result,
|
||||
credits_remaining: balance.remaining, // null = unlimited (Max)
|
||||
tier,
|
||||
credit_charged: creditCharged,
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape but for error responses. The error reason goes in `error`
|
||||
// alongside `result: null`. Clients should still update their balance
|
||||
// display from `credits_remaining` so failed calls (which were
|
||||
// refunded) reflect the unchanged balance.
|
||||
export async function errorEnvelope({
|
||||
error,
|
||||
installId,
|
||||
tier = "core",
|
||||
statusHint = 500,
|
||||
}) {
|
||||
let creditsRemaining = null;
|
||||
try {
|
||||
const quota = await getTierQuotas();
|
||||
const row = await getOrCreateRow(installId || "unknown");
|
||||
const balance = computeRemaining(row, quota);
|
||||
creditsRemaining = balance.remaining;
|
||||
} catch {}
|
||||
return {
|
||||
statusHint,
|
||||
body: {
|
||||
result: null,
|
||||
error: typeof error === "string" ? error : error?.message || "unknown_error",
|
||||
credits_remaining: creditsRemaining,
|
||||
tier,
|
||||
credit_charged: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// GET /relay/health — public liveness check. No auth, no credit
|
||||
// accounting. Returns a minimal status object so monitoring + Recap's
|
||||
// /api/relay/status can verify the relay is reachable.
|
||||
|
||||
import express from "express";
|
||||
import { getConfigSnapshot } from "../config.js";
|
||||
|
||||
export function healthRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/health", async (_req, res) => {
|
||||
const cfg = await getConfigSnapshot();
|
||||
res.json({
|
||||
ok: true,
|
||||
service: "recap-relay",
|
||||
version: "0.1.0",
|
||||
backends: {
|
||||
gemini: !!cfg.relay_gemini_api_key,
|
||||
parakeet: !!cfg.relay_parakeet_base_url,
|
||||
gemma: !!cfg.relay_gemma_base_url,
|
||||
},
|
||||
admin_enabled: !!cfg.relay_admin_password_hash,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// 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
|
||||
// }
|
||||
|
||||
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";
|
||||
|
||||
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 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;
|
||||
|
||||
// Persist tier on the row so the admin dashboard reflects the
|
||||
// most recently seen tier for this install.
|
||||
const row = await getOrCreateRow(installId);
|
||||
row.tier_snapshot = tier;
|
||||
|
||||
// Job-id dedup. If we've already charged this job, skip the
|
||||
// credit check entirely — the user is paying once for the whole
|
||||
// summarize job.
|
||||
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 plan = planBackend(row, quota, { hasHardware });
|
||||
if (!plan.allowed) {
|
||||
const e = await errorEnvelope({
|
||||
error: plan.reason,
|
||||
installId,
|
||||
tier,
|
||||
statusHint: 402,
|
||||
});
|
||||
return res.status(402).json(e.body);
|
||||
}
|
||||
chosenBackend = plan.backend;
|
||||
}
|
||||
|
||||
// Build the backend client based on chosenBackend.
|
||||
const cfg = await getConfigSnapshot();
|
||||
let result;
|
||||
try {
|
||||
if (chosenBackend === "gemini") {
|
||||
const backend = createGeminiBackend({ apiKey: cfg.relay_gemini_api_key });
|
||||
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,
|
||||
});
|
||||
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 we'd charged this job already (rare — most refundable
|
||||
// failures happen on the FIRST call), refund.
|
||||
if (reusedJob) refundJob(installId, jobId);
|
||||
console.error(`[relay/transcribe] 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);
|
||||
}
|
||||
|
||||
// Commit the credit on success (unless this was a job-id reuse).
|
||||
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;
|
||||
}
|
||||
|
||||
function parseChaptersField(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user