mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
feat(server): per-user AI usage tracking, cost alerting, and endpoint hardening
Usage tracking: - new UsageEvent collection records every STT/TTS/LLM call per user with provider, model, volume (audio seconds, characters, tokens) and an estimated USD cost; self-hosted providers (Speaches/Piper) and cache hits record at $0 - pricing table for whisper-1, tts-1, gpt-5-nano & co. in server/utils/usage.ts - weekly KPI mail gains an "AI-Nutzung & Kosten" section: weekly and rolling 30-day cost, per-kind breakdown, top 5 users by cost - quota alert mail when rolling 30-day cost exceeds USAGE_ALERT_USD (default $5), at most once per calendar month (UsageAlertDelivery) Hardening: - /api/atc/say now requires an authenticated session (middleware exemption removed); useFlightLabAudio sends the bearer token - /api/service/tools/latency requires auth (was a public LLM endpoint) - per-user rate limits: PTT 20/min, say 60/min, latency 5/min - cron endpoints (waitlist-drip, weekly-kpi-report) require a shared secret via ?secret= or x-cron-secret (CRON_SECRET, falls back to KPI_CRON_SECRET); allowed with a warning while unset so existing deployments keep working - PTT records the actual transcribed audio duration for billing accuracy Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// server/api/atc/ptt.post.ts
|
||||
import { createError, readBody } from "h3";
|
||||
import { writeFile, rm } from "node:fs/promises";
|
||||
import { writeFile, rm, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -9,6 +9,8 @@ import { getOpenAIClient } from "../../utils/openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
import { enforceRateLimit, getClientIp } from "../../utils/rateLimit";
|
||||
import { recordUsage } from "../../utils/usage";
|
||||
|
||||
type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
|
||||
|
||||
@@ -66,6 +68,13 @@ function decodeAudioPayload(encoded: string): Buffer {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function wavDurationSeconds(buffer: Buffer): number | undefined {
|
||||
if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF') return undefined;
|
||||
const byteRate = buffer.readUInt32LE(28);
|
||||
if (!byteRate) return undefined;
|
||||
return Math.round(((buffer.length - 44) / byteRate) * 100) / 100;
|
||||
}
|
||||
|
||||
async function convertToWav(inputPath: string, outputPath: string) {
|
||||
await sh("ffmpeg", [
|
||||
"-y", "-i", inputPath,
|
||||
@@ -76,7 +85,12 @@ async function convertToWav(inputPath: string, outputPath: string) {
|
||||
]);
|
||||
}
|
||||
|
||||
const PTT_RATE_LIMIT_PER_MINUTE = 20;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await getUserFromEvent(event);
|
||||
enforceRateLimit(event, 'atc-ptt', user ? String(user._id) : getClientIp(event), PTT_RATE_LIMIT_PER_MINUTE);
|
||||
|
||||
const body = await readBody<PTTRequest>(event);
|
||||
|
||||
if (!body.audio || !body.moduleId || !body.lessonId) {
|
||||
@@ -115,6 +129,34 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const transcribedText = transcription.text.trim();
|
||||
|
||||
// Audio length for usage accounting. The Whisper input is 16kHz mono WAV
|
||||
// in the normal path; fall back to a byte-rate estimate if header parsing fails.
|
||||
let audioSeconds: number | undefined;
|
||||
try {
|
||||
const wavBuffer = audioFileForWhisper === tmpAudioInput && format === 'wav'
|
||||
? audioBuffer
|
||||
: await readFile(audioFileForWhisper);
|
||||
audioSeconds = wavDurationSeconds(wavBuffer) ?? Math.round((wavBuffer.length / 32000) * 100) / 100;
|
||||
} catch {
|
||||
audioSeconds = Math.round((audioBuffer.length / 32000) * 100) / 100;
|
||||
}
|
||||
|
||||
// Prefer the explicit top-level sessionId (Python backend session).
|
||||
// Fall back to the legacy context.flags.session_id for older clients.
|
||||
const sessionId = body.sessionId
|
||||
?? (typeof body.context?.flags?.session_id === 'string' ? body.context.flags.session_id : undefined);
|
||||
|
||||
// Whisper bills the audio even when nothing was recognized.
|
||||
await recordUsage({
|
||||
user: user?._id ? String(user._id) : undefined,
|
||||
sessionId,
|
||||
kind: 'stt',
|
||||
provider: 'openai',
|
||||
model: 'whisper-1',
|
||||
endpoint: '/api/atc/ptt',
|
||||
audioSeconds,
|
||||
});
|
||||
|
||||
if (!transcribedText) {
|
||||
throw createError({ statusCode: 400, statusMessage: "No speech detected in audio" });
|
||||
}
|
||||
@@ -125,12 +167,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUserFromEvent(event);
|
||||
// Prefer the explicit top-level sessionId (Python backend session).
|
||||
// Fall back to the legacy context.flags.session_id for older clients.
|
||||
const sessionId = body.sessionId
|
||||
?? (typeof body.context?.flags?.session_id === 'string' ? body.context.flags.session_id : undefined);
|
||||
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
role: "pilot",
|
||||
|
||||
@@ -7,6 +7,9 @@ import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
|
||||
import { getServerRuntimeConfig } from "../../utils/runtimeConfig";
|
||||
import {request} from "node:http";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { requireUserSession } from "../../utils/auth";
|
||||
import { enforceRateLimit } from "../../utils/rateLimit";
|
||||
import { recordUsage } from "../../utils/usage";
|
||||
|
||||
|
||||
function outDir() {
|
||||
@@ -157,7 +160,12 @@ async function speachesTTS(
|
||||
return Buffer.from(arr);
|
||||
}
|
||||
|
||||
const SAY_RATE_LIMIT_PER_MINUTE = 60;
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event);
|
||||
enforceRateLimit(event, 'atc-say', String(user._id), SAY_RATE_LIMIT_PER_MINUTE);
|
||||
|
||||
const runtimeConfig = getServerRuntimeConfig();
|
||||
const body = await readBody<{
|
||||
text?: string;
|
||||
@@ -177,8 +185,6 @@ export default defineEventHandler(async (event) => {
|
||||
preNormalized?: boolean;
|
||||
}>(event);
|
||||
|
||||
// const user = await requireUserSession(event);
|
||||
|
||||
const rawSessionId = typeof body?.sessionId === "string"
|
||||
? body.sessionId.trim()
|
||||
: "";
|
||||
@@ -348,9 +354,20 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
await recordUsage({
|
||||
user: String(user._id),
|
||||
sessionId,
|
||||
kind: 'tts',
|
||||
// Cache hits cost nothing regardless of which provider filled the cache.
|
||||
provider: cacheHit ? 'cache' : ttsProvider,
|
||||
model: modelUsed,
|
||||
endpoint: '/api/atc/say',
|
||||
characters: normalized.length,
|
||||
});
|
||||
|
||||
try {
|
||||
await TransmissionLog.create({
|
||||
// user: user._id,
|
||||
user: user._id,
|
||||
role: "atc",
|
||||
channel: "say",
|
||||
direction: "outgoing",
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
renderInvitationText,
|
||||
} from '../../../utils/invitations'
|
||||
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
|
||||
import { requireCronSecret } from '../../../utils/cron'
|
||||
import { maybeSendUsageQuotaAlert } from '../../../utils/usageAlert'
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24
|
||||
const INVITATION_DELAY_DAYS = 5
|
||||
@@ -93,7 +95,9 @@ async function sendWeeklyKpiReportIfDue(now: Date) {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireCronSecret(event)
|
||||
|
||||
const now = new Date()
|
||||
const invitationCutoff = new Date(now.getTime() - INVITATION_DELAY_DAYS * DAY_MS)
|
||||
const feedbackCutoff = new Date(now.getTime() - FEEDBACK_DELAY_DAYS * DAY_MS)
|
||||
@@ -188,9 +192,15 @@ export default defineEventHandler(async () => {
|
||||
const kpiReport = await sendWeeklyKpiReportIfDue(now)
|
||||
console.log(`[waitlist-drip] weekly KPI report: ${kpiReport.sent ? 'sent' : `skipped (${kpiReport.skipped})`}`)
|
||||
|
||||
const usageAlert = await maybeSendUsageQuotaAlert(now)
|
||||
if (usageAlert.sent) {
|
||||
console.log(`[waitlist-drip] usage quota alert sent ($${usageAlert.costUsd?.toFixed(4)})`)
|
||||
}
|
||||
|
||||
return {
|
||||
invitationsSent,
|
||||
feedbackRequests,
|
||||
kpiReport,
|
||||
usageAlert,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { createError, getQuery } from 'h3'
|
||||
import { sendMail } from '../../../utils/notifications'
|
||||
import { buildWeeklyKpiReport, renderWeeklyKpiEmail, renderWeeklyKpiText } from '../../../utils/kpiReport'
|
||||
import { requireCronSecret } from '../../../utils/cron'
|
||||
import { maybeSendUsageQuotaAlert } from '../../../utils/usageAlert'
|
||||
|
||||
const DEFAULT_KPI_RECIPIENT = 'opensquawk-kpi@faktorxmensch.com'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const secret = process.env.KPI_CRON_SECRET?.trim()
|
||||
if (secret) {
|
||||
const query = getQuery(event)
|
||||
const provided = typeof query.secret === 'string' ? query.secret : ''
|
||||
if (provided !== secret) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid KPI cron secret.' })
|
||||
}
|
||||
}
|
||||
requireCronSecret(event)
|
||||
|
||||
const report = await buildWeeklyKpiReport()
|
||||
const to = process.env.KPI_EMAIL_TO || DEFAULT_KPI_RECIPIENT
|
||||
@@ -25,6 +19,8 @@ export default defineEventHandler(async (event) => {
|
||||
html: renderWeeklyKpiEmail(report),
|
||||
})
|
||||
|
||||
const usageAlert = await maybeSendUsageQuotaAlert()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sent,
|
||||
@@ -34,5 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||
totals: report.totals,
|
||||
products: report.products,
|
||||
smartGoals: report.smartGoals,
|
||||
aiUsage: report.aiUsage,
|
||||
usageAlert,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { createError } from 'h3'
|
||||
import { getOpenAIClient } from '../../../utils/openai'
|
||||
import { getServerRuntimeConfig } from '../../../utils/runtimeConfig'
|
||||
import { requireUserSession } from '../../../utils/auth'
|
||||
import { enforceRateLimit } from '../../../utils/rateLimit'
|
||||
import { recordUsage } from '../../../utils/usage'
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
'Check if the pilot readback contains ALL of: Frankfurt or EDDF, FL320, and 120.8 MHz. ' +
|
||||
@@ -10,7 +13,10 @@ const SYSTEM_PROMPT =
|
||||
const READBACK =
|
||||
'Lufthanser four seven eight cleared fra via NORDA1A, climb 5000 feet, expect flight level tree too zero, dep 120 decimal 8, squawk 4213.';
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
enforceRateLimit(event, 'tools-latency', String(user._id), 5)
|
||||
|
||||
const client = getOpenAIClient()
|
||||
const { llmModel } = getServerRuntimeConfig()
|
||||
const model = llmModel || 'chatgpt-5-nano'
|
||||
@@ -32,6 +38,16 @@ export default defineEventHandler(async () => {
|
||||
const validResult = Number.isInteger(parsed) && parsed >= 0 && parsed <= 2 ? parsed : null
|
||||
const latencyMs = Date.now() - started
|
||||
|
||||
await recordUsage({
|
||||
user: String(user._id),
|
||||
kind: 'llm',
|
||||
provider: 'openai',
|
||||
model,
|
||||
endpoint: '/api/service/tools/latency',
|
||||
inputTokens: response.usage?.prompt_tokens,
|
||||
outputTokens: response.usage?.completion_tokens,
|
||||
})
|
||||
|
||||
return {
|
||||
result: validResult,
|
||||
raw,
|
||||
|
||||
Reference in New Issue
Block a user