/** * @factory/ai — usage ledger + budget enforcement (ai-architecture §7). * * Every gateway call (chat, embed, tool round) appends an `ai_usage` record * (in-memory here; the app layer persists to the `ai_usage` table via the * injected `sink`). Budgets are enforced at **submit time** (fast fail, * before tokens are spent): * * | Budget | Check | Exceed → * | tokensPerMonth | month-to-date + this call's estimated out | AI_BUDGET_EXCEEDED (402) * | dailySpendUsd | day-to-date cost (providers with prices) | AI_BUDGET_EXCEEDED (402) * | requestsPerMinute | sliding window (in-memory here; Redis in app)| RATE_LIMITED (429) * | maxOutputTokens | gateway clamps the request | silent clamp * * `DEFAULT_BUDGETS` is imported from `config.ts` (single source of truth). * * **Recorded decision (spec conflict, rule 29):** TASK-0121 requires the error * code `AI_BUDGET_EXCEEDED` (HTTP 402) and `SERVICE_UNAVAILABLE`, but the * frozen `@factory/shared` `ErrorCodes` contains neither (shared was delivered * and is not to be modified this phase). This package therefore keeps the * shared `ApiError` envelope (code `AI_ERROR`) and carries the machine- * readable code in `details.code` via {@link AiBudgetError} / * {@link AiServiceError}; the API layer maps `details.code` onto the canonical * table (which the task itself documents as extensible by design). */ import { ApiError, ErrorCodes } from "@factory/shared"; import { DEFAULT_BUDGETS } from "./config.js"; /** Extended AI error codes (see module doc — recorded decision). */ export const AiErrorCodes = { AI_BUDGET_EXCEEDED: "AI_BUDGET_EXCEEDED", SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE", } as const; export type AiErrorCode = (typeof AiErrorCodes)[keyof typeof AiErrorCodes]; export interface UsageRecord { id: string; workspaceId: string; userId?: string; capability?: string; kind: "chat" | "embed" | "generate"; model: string; provider: string; tokensIn: number; tokensOut: number; latencyMs: number; /** USD from the price table (0 for OSS). */ costUsd: number; requestKind: "stream" | "batch"; status: "ok" | "error" | "timeout"; createdAt: number; } export interface UsageSink { (record: UsageRecord): void | Promise; } let counter = 0; function nextId(): string { counter += 1; return `usage_${counter.toString(36)}_${Date.now().toString(36)}`; } /** * In-memory usage ledger with an optional persistence sink. Pure + * deterministic given an injected clock. The app layer wires the sink to * `ai_usage` (Postgres) and the rollup worker. */ export class UsageLedger { readonly records: UsageRecord[] = []; private readonly sink: UsageSink | undefined; private readonly now: () => number; constructor(options: { sink?: UsageSink; now?: () => number } = {}) { this.sink = options.sink; this.now = options.now ?? Date.now; } async record(input: Omit & { id?: string }): Promise { const record: UsageRecord = { id: input.id ?? nextId(), createdAt: this.now(), ...input }; this.records.push(record); if (this.sink) await this.sink(record); return record; } /** Sum of tokens (in+out) for a workspace since `sinceMs`. */ tokensSince(workspaceId: string, sinceMs: number): number { let sum = 0; for (const r of this.records) { if (r.workspaceId === workspaceId && r.createdAt >= sinceMs) sum += r.tokensIn + r.tokensOut; } return sum; } /** Sum of costUsd for a workspace since `sinceMs`. */ costSince(workspaceId: string, sinceMs: number): number { let sum = 0; for (const r of this.records) { if (r.workspaceId === workspaceId && r.createdAt >= sinceMs) sum += r.costUsd; } return sum; } /** Call count for a workspace since `sinceMs`. */ callsSince(workspaceId: string, sinceMs: number): number { let n = 0; for (const r of this.records) { if (r.workspaceId === workspaceId && r.createdAt >= sinceMs) n += 1; } return n; } /** Monthly rollup for one workspace: tokens, cost, calls (ai_usage_rollup shape). */ monthlyRollup(workspaceId: string, monthStartMs: number): { tokens: number; costUsd: number; calls: number } { return { tokens: this.tokensSince(workspaceId, monthStartMs), costUsd: this.costSince(workspaceId, monthStartMs), calls: this.callsSince(workspaceId, monthStartMs), }; } } /** Budget configuration (subset of AiConfig.budgets used by the guard). */ export interface Budgets { /** 0 = unlimited (or plan default). */ tokensPerMonth: number; /** Default 30. */ requestsPerMinute: number; /** Per-call output cap (default 4096). */ maxOutputTokens: number; /** 0 = unlimited within token budget (OSS). */ dailySpendUsd: number; } /** Budget-exceeded error (402). See module doc for the recorded code decision. */ export function budgetExceededError( reason: "monthly_token_budget_exceeded" | "daily_spend_budget_exceeded", used: number, limit: number, ): ApiError { return new ApiError(ErrorCodes.AI_ERROR, { status: 402, message: reason === "monthly_token_budget_exceeded" ? "Monthly AI token budget reached. Ask an admin." : "Daily AI spend budget reached. Ask an admin.", details: { code: AiErrorCodes.AI_BUDGET_EXCEEDED, reason, used, limit }, }); } /** * Submit-time budget guard. Checks run in order: rate limit → monthly tokens → * daily spend. The monthly token check uses the call's **estimated** output * (2× maxOutputTokens, per arch §7.2) so a huge generation cannot cross the * line before the stream starts. */ export class BudgetService { private readonly ledger: UsageLedger; private readonly now: () => number; /** Per-workspace sliding-window timestamps (in-memory; Redis `rl:ai:{id}` in app). */ private readonly windows = new Map(); /** Last admin-notification time per workspace (24 h throttle). */ private readonly lastNotification = new Map(); constructor(ledger: UsageLedger, now?: () => number) { this.ledger = ledger; this.now = now ?? Date.now; } /** Start of the current month (local time) in ms. */ private monthStart(): number { const d = new Date(this.now()); return new Date(d.getFullYear(), d.getMonth(), 1).getTime(); } /** Start of the current day (local time) in ms. */ private dayStart(): number { const d = new Date(this.now()); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } /** * Fast-fail budget check for a chat/embed call. Throws `AI_BUDGET_EXCEEDED` * (402) or `RATE_LIMITED` (429). `estimatedTokensOut` is the caller's * estimate of this call's output (the gateway uses 2× maxOutputTokens). */ check(workspaceId: string, budgets: Budgets, estimatedTokensOut = 0): void { const b = { ...DEFAULT_BUDGETS, ...budgets }; // 1) rate limit: sliding 60 s window const nowMs = this.now(); const windowMs = 60_000; let stamps = this.windows.get(workspaceId) ?? []; stamps = stamps.filter((t) => nowMs - t < windowMs); if (stamps.length >= b.requestsPerMinute) { this.windows.set(workspaceId, stamps); throw new ApiError(ErrorCodes.RATE_LIMITED, { message: "AI rate limit reached. Try again shortly.", details: { code: "RATE_LIMITED", limit: b.requestsPerMinute, windowSeconds: 60 }, }); } stamps.push(nowMs); this.windows.set(workspaceId, stamps); // 2) monthly token budget (0 = unlimited) if (b.tokensPerMonth > 0) { const used = this.ledger.tokensSince(workspaceId, this.monthStart()); if (used + estimatedTokensOut >= b.tokensPerMonth) { this.notifyBudgetExceeded(workspaceId); throw budgetExceededError("monthly_token_budget_exceeded", used, b.tokensPerMonth); } } // 3) daily spend (0 = unlimited) if (b.dailySpendUsd > 0) { const used = this.ledger.costSince(workspaceId, this.dayStart()); if (used >= b.dailySpendUsd) { this.notifyBudgetExceeded(workspaceId); throw budgetExceededError("daily_spend_budget_exceeded", used, b.dailySpendUsd); } } } /** Throttled admin notification (at most one per workspace per 24 h). */ notifyBudgetExceeded(workspaceId: string, emit?: (workspaceId: string) => void): boolean { const nowMs = this.now(); const last = this.lastNotification.get(workspaceId) ?? 0; if (nowMs - last < 24 * 3600 * 1000) return false; this.lastNotification.set(workspaceId, nowMs); emit?.(workspaceId); return true; } /** Clamp a requested maxTokens to the budget cap (silent, per arch §7.2). */ clampMaxTokens(requested: number | undefined, budgets: Budgets): number { const cap = { ...DEFAULT_BUDGETS, ...budgets }.maxOutputTokens; if (requested === undefined) return cap; return Math.min(requested, cap); } }