v0.2.8 operator dashboard with per-call audit log + cost tracking
This commit is contained in:
@@ -12,6 +12,8 @@ import { getConfigSnapshot } from "../config.js";
|
||||
import { snapshotAll } from "../credits.js";
|
||||
import { snapshotCache } from "../keysat-client.js";
|
||||
import { snapshotJobs } from "../job-credits.js";
|
||||
import { readEntries, aggregate } from "../audit-log.js";
|
||||
import { GEMINI_PRICING } from "../pricing.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
@@ -48,6 +50,49 @@ export function adminRouter({ dataDir }) {
|
||||
res.json({ entries: snapshotJobs() });
|
||||
});
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||
// Time-range aggregations over the per-call audit log. Default range
|
||||
// is the last 30 days; override with ?days=N or ?since=<ms-epoch>.
|
||||
// Returns { range, summary, by_tier, by_model, by_pipeline,
|
||||
// by_backend, by_install, by_hour_utc, cost_vs_speed, pricing }.
|
||||
router.get("/dashboard", async (req, res) => {
|
||||
const days =
|
||||
typeof req.query.days === "string"
|
||||
? parseInt(req.query.days, 10)
|
||||
: null;
|
||||
const explicitSince =
|
||||
typeof req.query.since === "string"
|
||||
? parseInt(req.query.since, 10)
|
||||
: null;
|
||||
const sinceMs =
|
||||
explicitSince && Number.isFinite(explicitSince)
|
||||
? explicitSince
|
||||
: Date.now() -
|
||||
(Number.isFinite(days) && days > 0 ? days : 30) *
|
||||
24 *
|
||||
3600 *
|
||||
1000;
|
||||
try {
|
||||
const entries = await readEntries({ sinceMs });
|
||||
const agg = aggregate(entries);
|
||||
res.json({
|
||||
range: {
|
||||
since_ms: sinceMs,
|
||||
until_ms: Date.now(),
|
||||
days: Number.isFinite(days) && days > 0 ? days : null,
|
||||
total_entries: entries.length,
|
||||
},
|
||||
...agg,
|
||||
pricing: GEMINI_PRICING,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[admin/dashboard] failed: ${err?.message || err}`);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "dashboard_failed", message: err?.message || String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
// 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";
|
||||
@@ -19,11 +23,14 @@ 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");
|
||||
@@ -65,6 +72,19 @@ export function analyzeRouter() {
|
||||
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,
|
||||
@@ -98,6 +118,21 @@ export function analyzeRouter() {
|
||||
} 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,
|
||||
@@ -114,6 +149,28 @@ export function analyzeRouter() {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
// 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";
|
||||
@@ -31,6 +35,8 @@ 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(),
|
||||
@@ -41,6 +47,7 @@ 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");
|
||||
@@ -60,14 +67,9 @@ export function transcribeRouter() {
|
||||
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);
|
||||
@@ -82,6 +84,19 @@ export function transcribeRouter() {
|
||||
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,
|
||||
@@ -93,7 +108,6 @@ export function transcribeRouter() {
|
||||
chosenBackend = plan.backend;
|
||||
}
|
||||
|
||||
// Build the backend client based on chosenBackend.
|
||||
const cfg = await getConfigSnapshot();
|
||||
let result;
|
||||
try {
|
||||
@@ -126,10 +140,23 @@ export function transcribeRouter() {
|
||||
});
|
||||
}
|
||||
} 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}`);
|
||||
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,
|
||||
@@ -139,7 +166,6 @@ export function transcribeRouter() {
|
||||
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 });
|
||||
@@ -147,6 +173,32 @@ export function transcribeRouter() {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user