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:
leubeem
2026-06-10 23:17:03 +02:00
parent 0907e011fd
commit 0b7345ced7
7 changed files with 355 additions and 14 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,