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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user