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>
2026-04-28 19:53:09 +02:00
|
|
|
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');
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
// 2026-04-27 was a Monday - week starts there.
|
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>
2026-04-28 19:53:09 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|