feat(ai): per-port token budgets + usage ledger for AI features

Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 19:53:09 +02:00
parent 2cf1bd9754
commit e7d23b254c
12 changed files with 10841 additions and 19 deletions

View File

@@ -24,6 +24,17 @@ export interface ParsedReceipt {
confidence: number;
}
export interface OcrUsage {
inputTokens: number;
outputTokens: number;
requestId: string | null;
}
export interface OcrRunResult {
parsed: ParsedReceipt;
usage: OcrUsage;
}
const EMPTY_RESULT: ParsedReceipt = {
establishment: null,
date: null,
@@ -61,12 +72,7 @@ function safeParse(content: string): ParsedReceipt {
}
}
async function runOpenAi({
imageBuffer,
mimeType,
apiKey,
model,
}: RunArgs): Promise<ParsedReceipt> {
async function runOpenAi({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
const client = new OpenAI({ apiKey });
const base64 = imageBuffer.toString('base64');
const response = await client.chat.completions.create({
@@ -87,15 +93,18 @@ async function runOpenAi({
max_tokens: 1024,
response_format: { type: 'json_object' },
});
return safeParse(response.choices[0]?.message?.content ?? '{}');
const parsed = safeParse(response.choices[0]?.message?.content ?? '{}');
return {
parsed,
usage: {
inputTokens: response.usage?.prompt_tokens ?? 0,
outputTokens: response.usage?.completion_tokens ?? 0,
requestId: response.id ?? null,
},
};
}
async function runClaude({
imageBuffer,
mimeType,
apiKey,
model,
}: RunArgs): Promise<ParsedReceipt> {
async function runClaude({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
const base64 = imageBuffer.toString('base64');
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
@@ -126,9 +135,21 @@ async function runClaude({
const detail = await res.text().catch(() => '');
throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`);
}
const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
const body = (await res.json()) as {
id?: string;
content?: Array<{ type: string; text?: string }>;
usage?: { input_tokens?: number; output_tokens?: number };
};
const text = body.content?.find((c) => c.type === 'text')?.text ?? '{}';
return safeParse(text);
const parsed = safeParse(text);
return {
parsed,
usage: {
inputTokens: body.usage?.input_tokens ?? 0,
outputTokens: body.usage?.output_tokens ?? 0,
requestId: body.id ?? null,
},
};
}
export async function runOcr(args: {
@@ -137,7 +158,7 @@ export async function runOcr(args: {
mimeType: string;
apiKey: string;
model: string;
}): Promise<ParsedReceipt> {
}): Promise<OcrRunResult> {
if (args.provider === 'openai') return runOpenAi(args);
return runClaude(args);
}
@@ -170,3 +191,6 @@ export async function testProvider(
return { ok: false, reason };
}
}
export const OCR_FEATURE = 'ocr_receipt';
export const OCR_ESTIMATED_TOKENS = 2048;