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>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
|
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
|
import { errorResponse } from '@/lib/errors';
|
|
import { logger } from '@/lib/logger';
|
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
|
import {
|
|
runOcr,
|
|
type ParsedReceipt,
|
|
OCR_FEATURE,
|
|
OCR_ESTIMATED_TOKENS,
|
|
} from '@/lib/services/ocr-providers';
|
|
import { checkBudget, recordAiUsage } from '@/lib/services/ai-budget.service';
|
|
|
|
const EMPTY: ParsedReceipt = {
|
|
establishment: null,
|
|
date: null,
|
|
amount: null,
|
|
currency: null,
|
|
lineItems: [],
|
|
confidence: 0,
|
|
};
|
|
|
|
export const POST = withAuth(
|
|
withPermission('expenses', 'create', async (req, ctx) => {
|
|
try {
|
|
const formData = await req.formData();
|
|
const file = formData.get('file') as File | null;
|
|
if (!file) {
|
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
|
}
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
const mimeType = file.type || 'image/jpeg';
|
|
|
|
const config = await getResolvedOcrConfig(ctx.portId);
|
|
// Tesseract.js (in-browser) is the default. The server only invokes
|
|
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
|
// and (b) a key resolves. Otherwise the client falls back to its
|
|
// local Tesseract result.
|
|
if (!config.aiEnabled) {
|
|
return NextResponse.json({
|
|
data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' },
|
|
});
|
|
}
|
|
if (!config.apiKey) {
|
|
return NextResponse.json({
|
|
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
|
|
});
|
|
}
|
|
|
|
// Per-port budget gate — refuse the call before we spend tokens
|
|
// when the port has already hit its hard cap, or when the request
|
|
// would push it past the cap. Soft-cap warnings ride along on the
|
|
// success response so the UI can show a banner without blocking.
|
|
const budget = await checkBudget({
|
|
portId: ctx.portId,
|
|
estimatedTokens: OCR_ESTIMATED_TOKENS,
|
|
});
|
|
if (!budget.ok) {
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: EMPTY,
|
|
source: 'manual',
|
|
reason: 'budget-exceeded',
|
|
providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`,
|
|
},
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await runOcr({
|
|
provider: config.provider,
|
|
model: config.model,
|
|
apiKey: config.apiKey,
|
|
imageBuffer: buffer,
|
|
mimeType,
|
|
});
|
|
await recordAiUsage({
|
|
portId: ctx.portId,
|
|
userId: ctx.userId,
|
|
feature: OCR_FEATURE,
|
|
provider: config.provider,
|
|
model: config.model,
|
|
inputTokens: result.usage.inputTokens,
|
|
outputTokens: result.usage.outputTokens,
|
|
requestId: result.usage.requestId,
|
|
});
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: result.parsed,
|
|
source: 'ai',
|
|
provider: config.provider,
|
|
model: config.model,
|
|
softCapWarning: budget.softCap,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
|
// Provider hiccup — degrade to manual entry rather than 500-ing.
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: EMPTY,
|
|
source: 'manual',
|
|
reason: 'provider-error',
|
|
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|