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:
46
src/app/api/v1/admin/ai-budget/route.ts
Normal file
46
src/app/api/v1/admin/ai-budget/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
getAiBudget,
|
||||||
|
setAiBudget,
|
||||||
|
currentPeriodTokens,
|
||||||
|
periodBreakdown,
|
||||||
|
} from '@/lib/services/ai-budget.service';
|
||||||
|
|
||||||
|
const saveSchema = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
softCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
|
||||||
|
hardCapTokens: z.number().int().nonnegative().max(100_000_000).optional(),
|
||||||
|
period: z.enum(['day', 'week', 'month']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const [budget, used, breakdown] = await Promise.all([
|
||||||
|
getAiBudget(ctx.portId),
|
||||||
|
currentPeriodTokens(ctx.portId),
|
||||||
|
periodBreakdown(ctx.portId),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ data: { budget, used, breakdown } });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PUT = withAuth(
|
||||||
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, saveSchema);
|
||||||
|
const next = await setAiBudget(ctx.portId, body, ctx.userId);
|
||||||
|
return NextResponse.json({ data: next });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -4,7 +4,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
import { runOcr, type ParsedReceipt } from '@/lib/services/ocr-providers';
|
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 = {
|
const EMPTY: ParsedReceipt = {
|
||||||
establishment: null,
|
establishment: null,
|
||||||
@@ -42,16 +48,51 @@ export const POST = withAuth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const parsed = await runOcr({
|
const result = await runOcr({
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
imageBuffer: buffer,
|
imageBuffer: buffer,
|
||||||
mimeType,
|
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({
|
return NextResponse.json({
|
||||||
data: { parsed, source: 'ai', provider: config.provider, model: config.model },
|
data: {
|
||||||
|
parsed: result.parsed,
|
||||||
|
source: 'ai',
|
||||||
|
provider: config.provider,
|
||||||
|
model: config.model,
|
||||||
|
softCapWarning: budget.softCap,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
||||||
|
|||||||
195
src/components/admin/ai-budget-card.tsx
Normal file
195
src/components/admin/ai-budget-card.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
type Period = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
interface BudgetResp {
|
||||||
|
data: {
|
||||||
|
budget: { enabled: boolean; softCapTokens: number; hardCapTokens: number; period: Period };
|
||||||
|
used: number;
|
||||||
|
breakdown: Array<{ feature: string; tokens: number; calls: number }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNum(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiBudgetCard() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const queryKey = ['admin-ai-budget'];
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<BudgetResp>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => apiFetch<BudgetResp>('/api/v1/admin/ai-budget'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [softCap, setSoftCap] = useState('100000');
|
||||||
|
const [hardCap, setHardCap] = useState('500000');
|
||||||
|
const [period, setPeriod] = useState<Period>('month');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.data) return;
|
||||||
|
setEnabled(data.data.budget.enabled);
|
||||||
|
setSoftCap(String(data.data.budget.softCapTokens));
|
||||||
|
setHardCap(String(data.data.budget.hardCapTokens));
|
||||||
|
setPeriod(data.data.budget.period);
|
||||||
|
}, [data?.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch('/api/v1/admin/ai-budget', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
enabled,
|
||||||
|
softCapTokens: Number.parseInt(softCap || '0', 10),
|
||||||
|
hardCapTokens: Number.parseInt(hardCap || '0', 10),
|
||||||
|
period,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI cost guardrails</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = data?.data.used ?? 0;
|
||||||
|
const hard = data?.data.budget.hardCapTokens ?? 0;
|
||||||
|
const soft = data?.data.budget.softCapTokens ?? 0;
|
||||||
|
const pctOfHard = hard > 0 ? Math.min(100, Math.round((used / hard) * 100)) : 0;
|
||||||
|
const breakdown = data?.data.breakdown ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>AI cost guardrails</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cap how many AI tokens this port can spend per period. The hard cap blocks new calls; the
|
||||||
|
soft cap surfaces a warning banner. Tokens are the unit both OpenAI and Anthropic bill on,
|
||||||
|
so the cap survives model price changes.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
This {period}: {formatNum(used)} tokens
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
soft {formatNum(soft)} · hard {formatNum(hard)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${
|
||||||
|
used >= hard ? 'bg-destructive' : used >= soft ? 'bg-amber-500' : 'bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${pctOfHard}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{breakdown.length > 0 ? (
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5 pt-1">
|
||||||
|
{breakdown.map((b) => (
|
||||||
|
<li key={b.feature} className="flex justify-between">
|
||||||
|
<span className="capitalize">{b.feature.replace(/_/g, ' ')}</span>
|
||||||
|
<span>
|
||||||
|
{formatNum(b.tokens)} tokens · {b.calls} call{b.calls === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="ai-budget-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) => setEnabled(v === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="ai-budget-enabled" className="text-sm font-medium">
|
||||||
|
Enforce token caps for this port
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When off, usage is still recorded for visibility but no requests are blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="period">Period</Label>
|
||||||
|
<Select value={period} onValueChange={(v) => setPeriod(v as Period)}>
|
||||||
|
<SelectTrigger id="period">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="day">Day (UTC)</SelectItem>
|
||||||
|
<SelectItem value="week">Week (Mon–Sun UTC)</SelectItem>
|
||||||
|
<SelectItem value="month">Calendar month (UTC)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="soft-cap">Soft cap (tokens)</Label>
|
||||||
|
<Input
|
||||||
|
id="soft-cap"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={softCap}
|
||||||
|
onChange={(e) => setSoftCap(e.target.value)}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="hard-cap">Hard cap (tokens)</Label>
|
||||||
|
<Input
|
||||||
|
id="hard-cap"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={hardCap}
|
||||||
|
onChange={(e) => setHardCap(e.target.value)}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||||
|
{save.isPending ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
||||||
|
Save guardrails
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { AiBudgetCard } from '@/components/admin/ai-budget-card';
|
||||||
|
|
||||||
type Provider = 'openai' | 'claude';
|
type Provider = 'openai' | 'claude';
|
||||||
|
|
||||||
@@ -302,6 +303,8 @@ export function OcrSettingsForm() {
|
|||||||
showUseGlobal
|
showUseGlobal
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AiBudgetCard />
|
||||||
|
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
<SettingsBlock
|
<SettingsBlock
|
||||||
scope="global"
|
scope="global"
|
||||||
|
|||||||
18
src/lib/db/migrations/0017_tiny_mercury.sql
Normal file
18
src/lib/db/migrations/0017_tiny_mercury.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE "ai_usage_ledger" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"feature" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"input_tokens" integer DEFAULT 0 NOT NULL,
|
||||||
|
"output_tokens" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_tokens" integer DEFAULT 0 NOT NULL,
|
||||||
|
"request_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_usage_ledger" ADD CONSTRAINT "ai_usage_ledger_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai_usage_ledger" ADD CONSTRAINT "ai_usage_ledger_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_ai_usage_port_created" ON "ai_usage_ledger" USING btree ("port_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_ai_usage_port_feature_created" ON "ai_usage_ledger" USING btree ("port_id","feature","created_at");
|
||||||
10000
src/lib/db/migrations/meta/0017_snapshot.json
Normal file
10000
src/lib/db/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
|||||||
"when": 1777395538988,
|
"when": 1777395538988,
|
||||||
"tag": "0016_magical_spyke",
|
"tag": "0016_magical_spyke",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777398450555,
|
||||||
|
"tag": "0017_tiny_mercury",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/lib/db/schema/ai-usage.ts
Normal file
50
src/lib/db/schema/ai-usage.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* AI usage ledger.
|
||||||
|
*
|
||||||
|
* Every server-side AI provider call records one row here so admins can
|
||||||
|
* audit spend per port, per feature, per user. Per-port budgets (stored
|
||||||
|
* in `system_settings` under `ai.budget`) read this table to enforce
|
||||||
|
* soft warnings and hard caps.
|
||||||
|
*
|
||||||
|
* Token-denominated rather than dollar-denominated so the cap survives
|
||||||
|
* model price changes — and it's the unit both OpenAI and Anthropic
|
||||||
|
* SDKs return in `response.usage`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pgTable, text, timestamp, integer, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { user } from './users';
|
||||||
|
|
||||||
|
export const aiUsageLedger = pgTable(
|
||||||
|
'ai_usage_ledger',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
/** Optional — system-initiated calls (e.g. scheduled summarizers) won't have a user. */
|
||||||
|
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||||
|
/** Stable feature key: 'ocr', 'summary', 'embedding', 'reply_draft', etc. */
|
||||||
|
feature: text('feature').notNull(),
|
||||||
|
/** 'openai' | 'claude' | 'tesseract' (free, recorded for parity). */
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
model: text('model').notNull(),
|
||||||
|
inputTokens: integer('input_tokens').notNull().default(0),
|
||||||
|
outputTokens: integer('output_tokens').notNull().default(0),
|
||||||
|
/** input + output. Indexed and used for budget rollup queries. */
|
||||||
|
totalTokens: integer('total_tokens').notNull().default(0),
|
||||||
|
/** Provider-side request id for cross-referencing with provider logs. */
|
||||||
|
requestId: text('request_id'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_ai_usage_port_created').on(table.portId, table.createdAt),
|
||||||
|
index('idx_ai_usage_port_feature_created').on(table.portId, table.feature, table.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AiUsageRow = typeof aiUsageLedger.$inferSelect;
|
||||||
|
export type NewAiUsageRow = typeof aiUsageLedger.$inferInsert;
|
||||||
@@ -50,5 +50,8 @@ export * from './system';
|
|||||||
// Insights (Phase B): alerts, analytics_snapshots
|
// Insights (Phase B): alerts, analytics_snapshots
|
||||||
export * from './insights';
|
export * from './insights';
|
||||||
|
|
||||||
|
// AI usage ledger (Phase 3b)
|
||||||
|
export * from './ai-usage';
|
||||||
|
|
||||||
// Relations (must come last — references all tables)
|
// Relations (must come last — references all tables)
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
222
src/lib/services/ai-budget.service.ts
Normal file
222
src/lib/services/ai-budget.service.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Per-port AI budget enforcement.
|
||||||
|
*
|
||||||
|
* Budgets are denominated in tokens (input + output) over a rolling
|
||||||
|
* window (day / week / month). Two thresholds:
|
||||||
|
* - softCapTokens: log a warning, surface a banner, but allow the call
|
||||||
|
* - hardCapTokens: refuse the call until the period rolls over
|
||||||
|
*
|
||||||
|
* Stored in `system_settings` under key `ai.budget` per port. Usage is
|
||||||
|
* accumulated in `ai_usage_ledger` and rolled up by SQL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, gte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export type BudgetPeriod = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
export interface AiBudget {
|
||||||
|
/** When false, the budget is disabled — no caps enforced. */
|
||||||
|
enabled: boolean;
|
||||||
|
softCapTokens: number;
|
||||||
|
hardCapTokens: number;
|
||||||
|
period: BudgetPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'ai.budget';
|
||||||
|
|
||||||
|
const DEFAULT_BUDGET: AiBudget = {
|
||||||
|
enabled: false,
|
||||||
|
softCapTokens: 100_000,
|
||||||
|
hardCapTokens: 500_000,
|
||||||
|
period: 'month',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readBudget(portId: string): Promise<AiBudget> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId)));
|
||||||
|
if (!row) return { ...DEFAULT_BUDGET };
|
||||||
|
const v = row.value as Partial<AiBudget>;
|
||||||
|
return {
|
||||||
|
enabled: v.enabled === true,
|
||||||
|
softCapTokens:
|
||||||
|
typeof v.softCapTokens === 'number' ? v.softCapTokens : DEFAULT_BUDGET.softCapTokens,
|
||||||
|
hardCapTokens:
|
||||||
|
typeof v.hardCapTokens === 'number' ? v.hardCapTokens : DEFAULT_BUDGET.hardCapTokens,
|
||||||
|
period: v.period === 'day' || v.period === 'week' || v.period === 'month' ? v.period : 'month',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAiBudget(portId: string): Promise<AiBudget> {
|
||||||
|
return readBudget(portId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiBudget(
|
||||||
|
portId: string,
|
||||||
|
input: Partial<AiBudget>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<AiBudget> {
|
||||||
|
const existing = await readBudget(portId);
|
||||||
|
const next: AiBudget = {
|
||||||
|
enabled: input.enabled ?? existing.enabled,
|
||||||
|
softCapTokens: input.softCapTokens ?? existing.softCapTokens,
|
||||||
|
hardCapTokens: input.hardCapTokens ?? existing.hardCapTokens,
|
||||||
|
period: input.period ?? existing.period,
|
||||||
|
};
|
||||||
|
if (next.softCapTokens < 0 || next.hardCapTokens < 0) {
|
||||||
|
throw new Error('Token caps must be non-negative');
|
||||||
|
}
|
||||||
|
if (next.softCapTokens > next.hardCapTokens) {
|
||||||
|
throw new Error('softCapTokens cannot exceed hardCapTokens');
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.delete(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId)));
|
||||||
|
await db.insert(systemSettings).values({
|
||||||
|
key: KEY,
|
||||||
|
portId,
|
||||||
|
value: next as unknown as Record<string, unknown>,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the start-of-period UTC timestamp for the configured window. */
|
||||||
|
export function periodStart(period: BudgetPeriod, now: Date = new Date()): Date {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
if (period === 'day') return start;
|
||||||
|
if (period === 'week') {
|
||||||
|
// Reset to Monday 00:00 UTC.
|
||||||
|
const dow = (start.getUTCDay() + 6) % 7; // 0 = Monday
|
||||||
|
start.setUTCDate(start.getUTCDate() - dow);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
// month
|
||||||
|
start.setUTCDate(1);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total tokens used in the current period, optionally filtered by feature. */
|
||||||
|
export async function currentPeriodTokens(portId: string, feature?: string): Promise<number> {
|
||||||
|
const budget = await readBudget(portId);
|
||||||
|
const since = periodStart(budget.period);
|
||||||
|
const filters = [eq(aiUsageLedger.portId, portId), gte(aiUsageLedger.createdAt, since)];
|
||||||
|
if (feature) filters.push(eq(aiUsageLedger.feature, feature));
|
||||||
|
const [row] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(${aiUsageLedger.totalTokens}), 0)` })
|
||||||
|
.from(aiUsageLedger)
|
||||||
|
.where(and(...filters));
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetCheckResult =
|
||||||
|
| { ok: true; remaining: number; usedTokens: number; softCap: boolean }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: 'hard-cap-exceeded' | 'budget-disabled-but-no-key' | 'estimated-exceeds-cap';
|
||||||
|
usedTokens: number;
|
||||||
|
capTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight gate: should we let this call proceed? Pass an `estimatedTokens`
|
||||||
|
* value (e.g. max_tokens budget for the request) so we can refuse calls
|
||||||
|
* that would *guarantee* hitting the cap, not just blow past it later.
|
||||||
|
*/
|
||||||
|
export async function checkBudget(args: {
|
||||||
|
portId: string;
|
||||||
|
estimatedTokens: number;
|
||||||
|
}): Promise<BudgetCheckResult> {
|
||||||
|
const { portId, estimatedTokens } = args;
|
||||||
|
const budget = await readBudget(portId);
|
||||||
|
if (!budget.enabled) {
|
||||||
|
// Budget is off — usage still gets logged, but no caps enforced.
|
||||||
|
return { ok: true, remaining: Number.POSITIVE_INFINITY, usedTokens: 0, softCap: false };
|
||||||
|
}
|
||||||
|
const used = await currentPeriodTokens(portId);
|
||||||
|
const remaining = budget.hardCapTokens - used;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'hard-cap-exceeded',
|
||||||
|
usedTokens: used,
|
||||||
|
capTokens: budget.hardCapTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (estimatedTokens > remaining) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'estimated-exceeds-cap',
|
||||||
|
usedTokens: used,
|
||||||
|
capTokens: budget.hardCapTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
remaining,
|
||||||
|
usedTokens: used,
|
||||||
|
softCap: used > budget.softCapTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordUsageInput {
|
||||||
|
portId: string;
|
||||||
|
userId?: string | null;
|
||||||
|
feature: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
requestId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a ledger row. Never throws — logged failures degrade silently. */
|
||||||
|
export async function recordAiUsage(input: RecordUsageInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const total = (input.inputTokens || 0) + (input.outputTokens || 0);
|
||||||
|
await db.insert(aiUsageLedger).values({
|
||||||
|
portId: input.portId,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
feature: input.feature,
|
||||||
|
provider: input.provider,
|
||||||
|
model: input.model,
|
||||||
|
inputTokens: input.inputTokens,
|
||||||
|
outputTokens: input.outputTokens,
|
||||||
|
totalTokens: total,
|
||||||
|
requestId: input.requestId ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Don't fail the user-facing call because the ledger write hiccuped —
|
||||||
|
// we'd rather silently lose a row than blow up an OCR scan.
|
||||||
|
logger.error({ err, feature: input.feature }, 'recordAiUsage failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-feature breakdown for the current period — feeds the admin dashboard. */
|
||||||
|
export async function periodBreakdown(
|
||||||
|
portId: string,
|
||||||
|
): Promise<Array<{ feature: string; tokens: number; calls: number }>> {
|
||||||
|
const budget = await readBudget(portId);
|
||||||
|
const since = periodStart(budget.period);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
feature: aiUsageLedger.feature,
|
||||||
|
tokens: sql<number>`coalesce(sum(${aiUsageLedger.totalTokens}), 0)`,
|
||||||
|
calls: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(aiUsageLedger)
|
||||||
|
.where(and(eq(aiUsageLedger.portId, portId), gte(aiUsageLedger.createdAt, since)))
|
||||||
|
.groupBy(aiUsageLedger.feature);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
feature: r.feature,
|
||||||
|
tokens: Number(r.tokens),
|
||||||
|
calls: Number(r.calls),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -24,6 +24,17 @@ export interface ParsedReceipt {
|
|||||||
confidence: number;
|
confidence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OcrUsage {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrRunResult {
|
||||||
|
parsed: ParsedReceipt;
|
||||||
|
usage: OcrUsage;
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_RESULT: ParsedReceipt = {
|
const EMPTY_RESULT: ParsedReceipt = {
|
||||||
establishment: null,
|
establishment: null,
|
||||||
date: null,
|
date: null,
|
||||||
@@ -61,12 +72,7 @@ function safeParse(content: string): ParsedReceipt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runOpenAi({
|
async function runOpenAi({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
|
||||||
imageBuffer,
|
|
||||||
mimeType,
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
}: RunArgs): Promise<ParsedReceipt> {
|
|
||||||
const client = new OpenAI({ apiKey });
|
const client = new OpenAI({ apiKey });
|
||||||
const base64 = imageBuffer.toString('base64');
|
const base64 = imageBuffer.toString('base64');
|
||||||
const response = await client.chat.completions.create({
|
const response = await client.chat.completions.create({
|
||||||
@@ -87,15 +93,18 @@ async function runOpenAi({
|
|||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
response_format: { type: 'json_object' },
|
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({
|
async function runClaude({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
|
||||||
imageBuffer,
|
|
||||||
mimeType,
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
}: RunArgs): Promise<ParsedReceipt> {
|
|
||||||
const base64 = imageBuffer.toString('base64');
|
const base64 = imageBuffer.toString('base64');
|
||||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -126,9 +135,21 @@ async function runClaude({
|
|||||||
const detail = await res.text().catch(() => '');
|
const detail = await res.text().catch(() => '');
|
||||||
throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`);
|
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 ?? '{}';
|
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: {
|
export async function runOcr(args: {
|
||||||
@@ -137,7 +158,7 @@ export async function runOcr(args: {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
}): Promise<ParsedReceipt> {
|
}): Promise<OcrRunResult> {
|
||||||
if (args.provider === 'openai') return runOpenAi(args);
|
if (args.provider === 'openai') return runOpenAi(args);
|
||||||
return runClaude(args);
|
return runClaude(args);
|
||||||
}
|
}
|
||||||
@@ -170,3 +191,6 @@ export async function testProvider(
|
|||||||
return { ok: false, reason };
|
return { ok: false, reason };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const OCR_FEATURE = 'ocr_receipt';
|
||||||
|
export const OCR_ESTIMATED_TOKENS = 2048;
|
||||||
|
|||||||
213
tests/integration/ai-budget.test.ts
Normal file
213
tests/integration/ai-budget.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import {
|
||||||
|
checkBudget,
|
||||||
|
currentPeriodTokens,
|
||||||
|
getAiBudget,
|
||||||
|
periodBreakdown,
|
||||||
|
periodStart,
|
||||||
|
recordAiUsage,
|
||||||
|
setAiBudget,
|
||||||
|
} from '@/lib/services/ai-budget.service';
|
||||||
|
import { makePort } from '../helpers/factories';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(systemSettings).where(eq(systemSettings.key, 'ai.budget'));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ai-budget service', () => {
|
||||||
|
it('defaults to disabled with sane caps', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const b = await getAiBudget(port.id);
|
||||||
|
expect(b.enabled).toBe(false);
|
||||||
|
expect(b.softCapTokens).toBeGreaterThan(0);
|
||||||
|
expect(b.hardCapTokens).toBeGreaterThanOrEqual(b.softCapTokens);
|
||||||
|
expect(b.period).toBe('month');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a custom budget and rejects soft > hard', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const saved = await setAiBudget(
|
||||||
|
port.id,
|
||||||
|
{ enabled: true, softCapTokens: 10_000, hardCapTokens: 50_000, period: 'week' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(saved.enabled).toBe(true);
|
||||||
|
expect(saved.softCapTokens).toBe(10_000);
|
||||||
|
expect(saved.hardCapTokens).toBe(50_000);
|
||||||
|
expect(saved.period).toBe('week');
|
||||||
|
|
||||||
|
await expect(setAiBudget(port.id, { softCapTokens: 60_000 }, 'u1')).rejects.toThrow(
|
||||||
|
/soft.*cannot exceed hard/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records usage and rolls into currentPeriodTokens', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 1000,
|
||||||
|
outputTokens: 200,
|
||||||
|
});
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'summary',
|
||||||
|
provider: 'claude',
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
inputTokens: 500,
|
||||||
|
outputTokens: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = await currentPeriodTokens(port.id);
|
||||||
|
expect(total).toBe(1800);
|
||||||
|
|
||||||
|
const ocrOnly = await currentPeriodTokens(port.id, 'ocr');
|
||||||
|
expect(ocrOnly).toBe(1200);
|
||||||
|
|
||||||
|
const breakdown = await periodBreakdown(port.id);
|
||||||
|
expect(breakdown.find((r) => r.feature === 'ocr')?.tokens).toBe(1200);
|
||||||
|
expect(breakdown.find((r) => r.feature === 'summary')?.tokens).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkBudget passes when disabled regardless of usage', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
// Stuff the ledger with a huge usage row.
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 10_000_000,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
const r = await checkBudget({ portId: port.id, estimatedTokens: 5000 });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkBudget refuses when hard cap is already exceeded', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await setAiBudget(
|
||||||
|
port.id,
|
||||||
|
{ enabled: true, softCapTokens: 1000, hardCapTokens: 5000, period: 'month' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 6000,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
const r = await checkBudget({ portId: port.id, estimatedTokens: 100 });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.reason).toBe('hard-cap-exceeded');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkBudget refuses when estimated would push past the cap', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await setAiBudget(
|
||||||
|
port.id,
|
||||||
|
{ enabled: true, softCapTokens: 1000, hardCapTokens: 5000, period: 'month' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 4500,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
const r = await checkBudget({ portId: port.id, estimatedTokens: 1000 });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.reason).toBe('estimated-exceeds-cap');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkBudget signals soft-cap warning when usage > soft but < hard', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await setAiBudget(
|
||||||
|
port.id,
|
||||||
|
{ enabled: true, softCapTokens: 1000, hardCapTokens: 5000, period: 'month' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: port.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 1500,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
const r = await checkBudget({ portId: port.id, estimatedTokens: 100 });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.softCap).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('periodStart honors day/week/month boundaries (UTC)', () => {
|
||||||
|
const wed = new Date(Date.UTC(2026, 3, 29, 14, 30)); // Wed 2026-04-29 14:30 UTC
|
||||||
|
expect(periodStart('day', wed).toISOString()).toBe('2026-04-29T00:00:00.000Z');
|
||||||
|
// 2026-04-27 was a Monday — week starts there.
|
||||||
|
expect(periodStart('week', wed).toISOString()).toBe('2026-04-27T00:00:00.000Z');
|
||||||
|
expect(periodStart('month', wed).toISOString()).toBe('2026-04-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates usage by port (cross-port rows do not leak into rollups)', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: portA.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
await recordAiUsage({
|
||||||
|
portId: portB.id,
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 999_999,
|
||||||
|
outputTokens: 0,
|
||||||
|
});
|
||||||
|
expect(await currentPeriodTokens(portA.id)).toBe(100);
|
||||||
|
expect(await currentPeriodTokens(portB.id)).toBe(999_999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ai-budget ledger writes never throw', () => {
|
||||||
|
it('returns even when given a non-existent port (logs and continues)', async () => {
|
||||||
|
// recordAiUsage swallows DB errors so the user-facing call doesn't fail
|
||||||
|
// because the audit write hiccupped.
|
||||||
|
await expect(
|
||||||
|
recordAiUsage({
|
||||||
|
portId: 'nonexistent-port-id',
|
||||||
|
feature: 'ocr',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 0,
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
// No row should have been inserted with a nonexistent portId due to FK.
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(aiUsageLedger)
|
||||||
|
.where(eq(aiUsageLedger.portId, 'nonexistent-port-id'));
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user