Files
pn-new-crm/src/lib/services/ai-budget.service.ts

236 lines
7.4 KiB
TypeScript
Raw Normal View History

/**
* 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';
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
import { toAuditJson } from '@/lib/audit';
import { db } from '@/lib/db';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { systemSettings } from '@/lib/db/schema/system';
import { ValidationError } from '@/lib/errors';
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 ValidationError('Token caps must be non-negative');
}
if (next.softCapTokens > next.hardCapTokens) {
throw new ValidationError('softCapTokens cannot exceed hardCapTokens');
}
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
// True upsert (atomic on the (key, port_id) NULLS NOT DISTINCT index
// — migration 0047). Replaces a delete-then-insert pattern that had a
// race window where two concurrent updates could both DELETE and both
// INSERT, accumulating duplicates.
await db
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
.insert(systemSettings)
.values({
key: KEY,
portId,
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
value: toAuditJson(next),
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
updatedBy: userId,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson Address the CRITICAL + high-leverage HIGH items from the types-auditor: **C1 — `tx: any` in client-restore.service** Export a canonical `Tx` type from `lib/db/utils.ts` (derived from Drizzle's `db.transaction` callback shape) and use it in `applyReversal` so the 12+ downstream tx writes get full inference. **C2 — berth-detail page stacked `useQuery<any>` escape hatches** Export `BerthDetailData` from berth-detail-header and consume it through useQuery + apiFetch. Removed three `any` escapes in the highest-traffic detail page. Also collapsed the duplicate `BerthData` in berth-tabs.tsx to import from berth-detail-header so the two types can't drift. **C3 — parseBody migration for portal/public routes** Replace raw `await req.json() + schema.parse(body)` with the project-standard `parseBody(req, schema)` helper across 7 routes: - portal/auth/{change-password, activate, reset-password} - auth/set-password - public/{interests, residential-inquiries} Skipped the three anti-enumeration routes (forgot-password, sign-in, sign-in-by-identifier) where the manual validation gives opaque errors on purpose. website-inquiries already wraps the parse in a custom 400 — left as-is. **HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)** Introduce `toAuditJson<T extends object>(row: T): Record<string, unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow` that already exists for the same reason). Codemod 21 `<row> as unknown as Record<string, unknown>` sites across: - invoices.ts × 6 - expenses.ts × 6 - berths.service × 2 - documents.service × 2 - ocr-config.service × 2 - ai-budget.service × 2 - yachts.service, companies.service, company-memberships.service × 1 each document-templates' `payload as unknown as Record<...>` is a different shape (Documenso form-values widening, not an audit log) — kept the manual cast there. Tests stay 1315/1315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
value: toAuditJson(next),
fix(audit): non-Documenso backlog sweep — port-binding, NULLS NOT DISTINCT, custom merge tokens, company docs Wave through the remaining audit-final-deferred items that aren't blocked on the back-burnered Documenso work. Multi-tenant isolation: - Storage proxy ProxyTokenPayload gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. Defense-in-depth against a buggy issuer in some future code path that mixes port scopes — every storage key generated by generateStorageKey() already prefixes the slug. document-sends opts in for 24h emailed download links; other callers continue working unchanged via the optional field. DB schema reconciliation: - Migration 0047 rebuilds system_settings unique index with NULLS NOT DISTINCT (Postgres 15+) so global settings (port_id IS NULL) are uniquely keyed by `key` alone. Surfaced + dedupe'd 65 duplicate (storage_backend, NULL) rows that had accumulated from race-prone delete-then-insert patterns in ocr-config / settings / residential- stages / ai-budget services. All four services converted to true onConflictDoUpdate upserts so the race window is closed. API uniformity: - Response shape standardization: 16 routes converted from `{ success: true }` to 204 No Content. CLAUDE.md documents the convention (`{ data: <T> }` for content, 204 for empty mutations, portal-auth retains `{ success: true }` for the frontend's auth chain). - req.json() → parseBody() migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url, versions, parse-results}). Uniform 400 error shapes for ZodError-flagged bodies. Custom-fields merge tokens (shipped end-to-end): - merge-fields.ts gains CUSTOM_MERGE_TOKEN_RE + helpers for the `{{custom.<fieldName>}}` shape. - document-templates validator accepts the dynamic shape alongside the static catalog tokens. - document-sends.service mergeCustomFieldValues resolver fetches per-port custom_field_definitions for client/interest/berth contexts and substitutes stored values keyed by `{{custom.fieldName}}`. - custom-fields-manager amber banner updated to reflect that merge tokens now expand (search index + entity-diff remain documented design limitations). /api/v1/files cross-entity filtering: - Validator + listFiles + uploadFile accept companyId AND yachtId alongside clientId. file-upload-zone propagates both. - New CompanyFilesTab component mirrors ClientFilesTab; restored as a visible Documents tab in company-tabs.tsx (was a hidden stub). Inline TODOs: - Reviewed remaining two TODOs (per-user reminder schedule, import worker handlers). Both are placeholders for future feature surfaces, not bugs — per-port digest works for every customer; nothing currently enqueues import jobs (verified). Annotated in BACKLOG. BACKLOG.md updated to reflect what landed and what's still pending (Documenso-related items still bundled with the back-burnered phases). Tests: 1185/1185 vitest, tsc clean.
2026-05-08 02:20:27 +02:00
updatedBy: userId,
updatedAt: new Date(),
},
});
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),
}));
}