diff --git a/public/dashboard.html b/public/dashboard.html
new file mode 100644
index 0000000..daac31d
--- /dev/null
+++ b/public/dashboard.html
@@ -0,0 +1,437 @@
+
+
+
+
+
+ Recap Relay — Operator Dashboard
+
+
+
+
+
+
+
+
diff --git a/server/audit-log.js b/server/audit-log.js
new file mode 100644
index 0000000..89f8a6e
--- /dev/null
+++ b/server/audit-log.js
@@ -0,0 +1,239 @@
+// Per-call audit log for profitability + observability. Each relay
+// request (success or failure) appends one line of newline-delimited
+// JSON to /data/relay-calls.ndjson. Append-only — read paths parse
+// the whole file in memory for aggregation, which is cheap up to
+// 100k+ entries at typical relay scale (low-tens-of-thousands of
+// calls per month).
+//
+// Record shape (no field is required; missing fields just don't
+// appear in aggregations):
+// {
+// ts: ms-epoch when the request landed
+// install_id: X-Recap-Install-Id (truncated for log readability)
+// tier: "core" | "pro" | "max"
+// pipeline: "transcribe" | "analyze"
+// backend: "gemini" | "hardware"
+// model: e.g. "gemini-3-flash-preview", "parakeet-tdt-0.6b-v3"
+// status: "success" | "error" | "refused" (refused = quota)
+// credit_charged: 0 | 1
+// duration_ms: end-to-end wall time
+// input_tokens, output_tokens, thinking_tokens (Gemini only)
+// cost_usd: computed from token counts × per-1M-token rates
+// job_id: X-Recap-Job-Id (so we can collapse pairs into one)
+// error: short error string if status="error"
+// }
+//
+// Rotation isn't built in — for the prototype, operator can rotate
+// manually (mv relay-calls.ndjson relay-calls.ndjson.0; restart). Once
+// volume warrants, replace this with a daily-rotated logger or move to
+// SQLite for indexed time-range queries.
+
+import fs from "fs/promises";
+import { createReadStream } from "fs";
+import readline from "readline";
+import path from "path";
+
+let dataDir = "/data";
+let logPath = "/data/relay-calls.ndjson";
+
+export async function initAuditLog({ dataDir: dd }) {
+ if (dd) dataDir = dd;
+ logPath = path.join(dataDir, "relay-calls.ndjson");
+ // Ensure the file exists so the streaming read path doesn't trip.
+ try {
+ await fs.access(logPath);
+ } catch {
+ await fs.writeFile(logPath, "", { mode: 0o600 });
+ }
+ console.log(`[audit-log] writing to ${logPath}`);
+}
+
+// Best-effort append. Errors are logged but never rethrown — losing
+// an audit line shouldn't fail the relay call that caused it.
+export async function recordCall(entry) {
+ const record = { ts: Date.now(), ...entry };
+ try {
+ await fs.appendFile(logPath, JSON.stringify(record) + "\n", { mode: 0o600 });
+ } catch (err) {
+ console.error(`[audit-log] append failed: ${err?.message || err}`);
+ }
+}
+
+// Read all entries since `sinceMs` (default: 30 days). Streamed
+// line-by-line so the whole file doesn't sit in memory at once.
+// Returned array is newest-first.
+export async function readEntries({
+ sinceMs = Date.now() - 30 * 24 * 3600 * 1000,
+ untilMs = Number.POSITIVE_INFINITY,
+} = {}) {
+ const out = [];
+ try {
+ const stream = createReadStream(logPath, { encoding: "utf8" });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ for await (const line of rl) {
+ if (!line.trim()) continue;
+ try {
+ const r = JSON.parse(line);
+ if (typeof r.ts === "number" && r.ts >= sinceMs && r.ts <= untilMs) {
+ out.push(r);
+ }
+ } catch {
+ // Bad line — skip silently. Doesn't disrupt the rest of the read.
+ }
+ }
+ } catch (err) {
+ if (err.code !== "ENOENT") {
+ console.error(`[audit-log] read failed: ${err?.message || err}`);
+ }
+ }
+ // Newest first by ts.
+ out.sort((a, b) => b.ts - a.ts);
+ return out;
+}
+
+// Compute multi-dimensional aggregates over a set of entries. The
+// dashboard renders all of these — each is a small object array
+// suitable for direct tabulation.
+export function aggregate(entries) {
+ const calls = entries.length;
+ const success = entries.filter((e) => e.status === "success").length;
+ const errors = entries.filter((e) => e.status === "error").length;
+ const refused = entries.filter((e) => e.status === "refused").length;
+
+ let totalCost = 0;
+ let totalDuration = 0;
+ let totalInputTokens = 0;
+ let totalOutputTokens = 0;
+ let totalThinkingTokens = 0;
+ for (const e of entries) {
+ totalCost += e.cost_usd || 0;
+ totalDuration += e.duration_ms || 0;
+ totalInputTokens += e.input_tokens || 0;
+ totalOutputTokens += e.output_tokens || 0;
+ totalThinkingTokens += e.thinking_tokens || 0;
+ }
+
+ // ── By tier ──
+ const byTier = groupBy(entries, (e) => e.tier || "unknown");
+ const tierRows = Object.entries(byTier).map(([tier, list]) => ({
+ tier,
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ avg_duration_ms: avgBy(list, "duration_ms"),
+ unique_installs: new Set(list.map((e) => e.install_id)).size,
+ }));
+
+ // ── By model ──
+ const byModel = groupBy(entries, (e) => e.model || "unknown");
+ const modelRows = Object.entries(byModel).map(([model, list]) => ({
+ model,
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ input_tokens: sumBy(list, "input_tokens"),
+ output_tokens: sumBy(list, "output_tokens"),
+ thinking_tokens: sumBy(list, "thinking_tokens"),
+ avg_duration_ms: avgBy(list, "duration_ms"),
+ avg_cost_usd: list.length > 0 ? sumBy(list, "cost_usd") / list.length : 0,
+ }));
+
+ // ── By pipeline ──
+ const byPipeline = groupBy(entries, (e) => e.pipeline || "unknown");
+ const pipelineRows = Object.entries(byPipeline).map(([pipeline, list]) => ({
+ pipeline,
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ avg_duration_ms: avgBy(list, "duration_ms"),
+ }));
+
+ // ── By backend ──
+ const byBackend = groupBy(entries, (e) => e.backend || "unknown");
+ const backendRows = Object.entries(byBackend).map(([backend, list]) => ({
+ backend,
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ avg_duration_ms: avgBy(list, "duration_ms"),
+ }));
+
+ // ── By install (top 20 by spend) ──
+ const byInstall = groupBy(entries, (e) => e.install_id || "unknown");
+ const installRows = Object.entries(byInstall)
+ .map(([install, list]) => ({
+ install_id: install,
+ tier_snapshot: list[0]?.tier || "core",
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ // Distinct summarize jobs (collapse transcribe+analyze pairs).
+ summaries: new Set(list.map((e) => e.job_id).filter(Boolean)).size,
+ avg_duration_ms: avgBy(list, "duration_ms"),
+ last_active_at: Math.max(...list.map((e) => e.ts || 0)),
+ }))
+ .sort((a, b) => b.cost_usd - a.cost_usd)
+ .slice(0, 20);
+
+ // ── By hour-of-day (for traffic-pattern view) ──
+ const byHour = groupBy(entries, (e) => new Date(e.ts).getUTCHours());
+ const hourRows = Array.from({ length: 24 }, (_, h) => {
+ const list = byHour[h] || [];
+ return {
+ hour_utc: h,
+ calls: list.length,
+ cost_usd: sumBy(list, "cost_usd"),
+ };
+ });
+
+ // ── Cost vs speed (per-model averages) ──
+ // Same source as modelRows but kept separate so the dashboard can
+ // render it as a scatter / table without extra transformation.
+ const costSpeedRows = modelRows
+ .map((r) => ({
+ model: r.model,
+ avg_cost_usd: r.avg_cost_usd,
+ avg_duration_ms: r.avg_duration_ms,
+ calls: r.calls,
+ }))
+ .sort((a, b) => a.avg_duration_ms - b.avg_duration_ms);
+
+ return {
+ summary: {
+ calls,
+ success,
+ errors,
+ refused,
+ success_rate: calls > 0 ? success / calls : 0,
+ total_cost_usd: totalCost,
+ total_duration_ms: totalDuration,
+ avg_duration_ms: calls > 0 ? totalDuration / calls : 0,
+ total_input_tokens: totalInputTokens,
+ total_output_tokens: totalOutputTokens,
+ total_thinking_tokens: totalThinkingTokens,
+ },
+ by_tier: tierRows,
+ by_model: modelRows,
+ by_pipeline: pipelineRows,
+ by_backend: backendRows,
+ by_install: installRows,
+ by_hour_utc: hourRows,
+ cost_vs_speed: costSpeedRows,
+ };
+}
+
+function groupBy(list, keyFn) {
+ const out = {};
+ for (const item of list) {
+ const k = keyFn(item);
+ if (!out[k]) out[k] = [];
+ out[k].push(item);
+ }
+ return out;
+}
+
+function sumBy(list, key) {
+ let s = 0;
+ for (const item of list) s += item[key] || 0;
+ return s;
+}
+
+function avgBy(list, key) {
+ if (list.length === 0) return 0;
+ return sumBy(list, key) / list.length;
+}
diff --git a/server/backends/gemini.js b/server/backends/gemini.js
index a1d4351..7851dd1 100644
--- a/server/backends/gemini.js
+++ b/server/backends/gemini.js
@@ -120,6 +120,10 @@ export function createGeminiBackend({
// that handles this exact shape.
segments: [],
duration_seconds: 0,
+ // Pass usage + the model id back to the route so audit-log
+ // entries can include token counts + computed cost.
+ usage: result?.usageMetadata || null,
+ model: transcriptionModel,
};
} finally {
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch {}
@@ -138,6 +142,8 @@ export function createGeminiBackend({
});
return {
text: safeText(result) || "",
+ usage: result?.usageMetadata || null,
+ model: analysisModel,
};
}
diff --git a/server/backends/hardware.js b/server/backends/hardware.js
index 98d431d..76618f7 100644
--- a/server/backends/hardware.js
+++ b/server/backends/hardware.js
@@ -147,6 +147,8 @@ export function createHardwareBackend({
text: lines.join("\n"),
segments: shifted,
duration_seconds: data.duration || 0,
+ usage: null, // hardware backend doesn't expose token counts
+ model: transcribeModel,
};
},
@@ -194,7 +196,11 @@ export function createHardwareBackend({
const data = await res.json();
const text = data?.choices?.[0]?.message?.content || "";
- return { text };
+ return {
+ text,
+ usage: null,
+ model: analyzeModel,
+ };
},
};
}
diff --git a/server/index.js b/server/index.js
index 740c0c7..79af6ac 100644
--- a/server/index.js
+++ b/server/index.js
@@ -14,6 +14,7 @@ import { fileURLToPath } from "url";
import { initConfig } from "./config.js";
import { initCredits } from "./credits.js";
+import { initAuditLog } from "./audit-log.js";
import {
setupAdminAuthMiddleware,
setupAdminAuthRoutes,
@@ -33,6 +34,7 @@ const PORT = parseInt(process.env.PORT || "3002", 10);
await initConfig({ dataDir: DATA_DIR });
await initCredits({ dataDir: DATA_DIR });
+await initAuditLog({ dataDir: DATA_DIR });
const app = express();
app.use(cors());
diff --git a/server/package.json b/server/package.json
index 550c817..1362f31 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "recap-relay-server",
- "version": "0.2.7",
+ "version": "0.2.8",
"type": "module",
"private": true,
"dependencies": {
diff --git a/server/pricing.js b/server/pricing.js
new file mode 100644
index 0000000..c0ebfd5
--- /dev/null
+++ b/server/pricing.js
@@ -0,0 +1,53 @@
+// Gemini per-1M-token pricing in USD. Used by the relay to compute
+// per-call API costs as audit-log entries are written. Operator can
+// update this table by editing the file and redeploying when Google
+// changes published rates — preferable to a config field because
+// (a) prices are stable for months at a time, (b) hardcoding keeps
+// the audit log self-contained without a config-snapshot copy at
+// write time.
+//
+// Rates as of mid-2026. ALWAYS verify against the current Google AI
+// Studio pricing page before relying on these for billing-grade
+// margin math — Google has been known to adjust prices ~quarterly.
+
+export const GEMINI_PRICING = {
+ // Pro family — best for analysis.
+ "gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
+ "gemini-3-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
+
+ // Flash family — best speed/cost for transcription, common for
+ // analysis when sub-Pro quality is acceptable.
+ "gemini-3-flash-preview": { input: 0.3, output: 2.5, thinking: 2.5 },
+ "gemini-2.5-flash": { input: 0.3, output: 2.5, thinking: 2.5 },
+ "gemini-2.0-flash": { input: 0.1, output: 0.4, thinking: 0.4 },
+
+ // Fallback used when an unknown model id appears (e.g. operator
+ // typed a custom model name in setBackendRouting). Conservative —
+ // priced like Flash so cost estimates skew low rather than 0.
+ default: { input: 0.3, output: 2.5, thinking: 2.5 },
+};
+
+// Compute cost for a Gemini API call given its model + usageMetadata
+// (the shape @google/genai returns: promptTokenCount,
+// candidatesTokenCount, thoughtsTokenCount). Returns:
+// { input_tokens, output_tokens, thinking_tokens, cost_usd }
+export function calcGeminiCost(model, usage) {
+ const rates = GEMINI_PRICING[model] || GEMINI_PRICING.default;
+ const inputTokens = usage?.promptTokenCount || 0;
+ const outputTokens = usage?.candidatesTokenCount || 0;
+ const thinkingTokens = usage?.thoughtsTokenCount || 0;
+ const costUSD =
+ (inputTokens / 1_000_000) * rates.input +
+ (outputTokens / 1_000_000) * rates.output +
+ (thinkingTokens / 1_000_000) * (rates.thinking ?? rates.output);
+ return {
+ input_tokens: inputTokens,
+ output_tokens: outputTokens,
+ thinking_tokens: thinkingTokens,
+ cost_usd: costUSD,
+ };
+}
+
+export function listKnownModels() {
+ return Object.keys(GEMINI_PRICING).filter((k) => k !== "default");
+}
diff --git a/server/routes/admin.js b/server/routes/admin.js
index 8aba063..eaf0ed0 100644
--- a/server/routes/admin.js
+++ b/server/routes/admin.js
@@ -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=.
+ // 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.
diff --git a/server/routes/analyze.js b/server/routes/analyze.js
index 4aa5608..fabec7e 100644
--- a/server/routes/analyze.js
+++ b/server/routes/analyze.js
@@ -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);
});
diff --git a/server/routes/transcribe.js b/server/routes/transcribe.js
index 700ee7c..31dc4fd 100644
--- a/server/routes/transcribe.js
+++ b/server/routes/transcribe.js
@@ -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);
});
diff --git a/startos/versions/index.ts b/startos/versions/index.ts
index 0329fef..c329daa 100644
--- a/startos/versions/index.ts
+++ b/startos/versions/index.ts
@@ -8,8 +8,9 @@ import { v_0_2_4 } from './v0.2.4'
import { v_0_2_5 } from './v0.2.5'
import { v_0_2_6 } from './v0.2.6'
import { v_0_2_7 } from './v0.2.7'
+import { v_0_2_8 } from './v0.2.8'
export const versionGraph = VersionGraph.of({
- current: v_0_2_7,
- other: [v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
+ current: v_0_2_8,
+ other: [v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
})
diff --git a/startos/versions/v0.2.8.ts b/startos/versions/v0.2.8.ts
new file mode 100644
index 0000000..d04a288
--- /dev/null
+++ b/startos/versions/v0.2.8.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_8 = VersionInfo.of({
+ version: '0.2.8:0',
+ releaseNotes: {
+ en_US:
+ 'New operator dashboard at /dashboard.html. Per-call audit log (NDJSON at /data/relay-calls.ndjson) captures install, tier, pipeline, backend, model, tokens, cost USD, duration, status, job_id. /admin/dashboard JSON endpoint returns aggregations by tier / model / pipeline / backend / install / hour-of-day plus cost-vs-speed table. HTML dashboard renders summary tiles + tables with inline bar charts; reuses the admin password gate for auth.',
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})