ai_api / src /domain /costRules.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
19.2 kB
/**
* Cost Rules — Domain Layer (T-19)
*
* Business rules for cost management: budget thresholds,
* scheduled reset windows, and cost summaries per API key.
*
* State is persisted in SQLite via domainState.ts.
*
* @module domain/costRules
*/
import {
deleteAllCostData,
deleteBudget as dbDeleteBudget,
deleteCostEntries,
loadAllBudgets,
loadBudget,
loadCostEntries,
loadCostEntriesInRange,
loadCostTotal,
saveBudget,
saveBudgetResetLog,
} from "../lib/db/domainState";
import {
discardSpendBatchEntries,
resetSpendBatchWriterForTests,
spendBatchWriter,
} from "@/lib/spend/batchWriter";
export type BudgetResetInterval = "daily" | "weekly" | "monthly";
interface BudgetConfig {
dailyLimitUsd?: number;
weeklyLimitUsd?: number;
monthlyLimitUsd?: number;
warningThreshold?: number;
resetInterval?: BudgetResetInterval;
resetTime?: string;
budgetResetAt?: number | null;
lastBudgetResetAt?: number | null;
warningEmittedAt?: number | null;
warningPeriodStart?: number | null;
}
interface NormalizedBudgetConfig {
dailyLimitUsd: number;
weeklyLimitUsd: number;
monthlyLimitUsd: number;
warningThreshold: number;
resetInterval: BudgetResetInterval;
resetTime: string;
budgetResetAt: number | null;
lastBudgetResetAt: number | null;
warningEmittedAt: number | null;
warningPeriodStart: number | null;
}
interface CostEntry {
cost: number;
timestamp: number;
}
interface BudgetWindow {
periodStartAt: number;
nextResetAt: number;
}
interface SyncBudgetScheduleOptions {
logReset?: boolean;
persist?: boolean;
}
interface BudgetSummary {
dailyTotal: number;
monthlyTotal: number;
totalEntries: number;
budget: NormalizedBudgetConfig | null;
totalCostToday: number;
totalCostMonth: number;
totalCostPeriod: number;
activeLimitUsd: number;
resetInterval: BudgetResetInterval | null;
resetTime: string | null;
budgetResetAt: number | null;
lastBudgetResetAt: number | null;
periodStartAt: number | null;
nextResetAt: number | null;
dailyLimitUsd: number;
weeklyLimitUsd: number;
monthlyLimitUsd: number;
warningThreshold: number | null;
}
const VALID_RESET_INTERVALS = new Set<BudgetResetInterval>(["daily", "weekly", "monthly"]);
const RESET_TIME_REGEX = /^(\d{2}):(\d{2})$/;
/** @type {Map<string, NormalizedBudgetConfig>} In-memory cache for budgets */
const budgets = new Map<string, NormalizedBudgetConfig>();
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function toCostEntries(value: unknown): CostEntry[] {
if (!Array.isArray(value)) return [];
const entries: CostEntry[] = [];
for (const item of value) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const cost = toNumber(record.cost, Number.NaN);
const timestamp = toNumber(record.timestamp, Number.NaN);
if (!Number.isFinite(cost) || !Number.isFinite(timestamp)) continue;
entries.push({ cost, timestamp });
}
return entries;
}
function sumEntries(entries: CostEntry[]): number {
return entries.reduce((sum, entry) => sum + entry.cost, 0);
}
function normalizeResetInterval(value: unknown): BudgetResetInterval {
if (typeof value === "string") {
const normalized = value.trim().toLowerCase() as BudgetResetInterval;
if (VALID_RESET_INTERVALS.has(normalized)) {
return normalized;
}
}
return "daily";
}
function normalizeResetTime(value: unknown): string {
if (typeof value === "string") {
const match = value.trim().match(RESET_TIME_REGEX);
if (match) {
const hours = Math.min(Math.max(parseInt(match[1], 10), 0), 23);
const minutes = Math.min(Math.max(parseInt(match[2], 10), 0), 59);
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
}
return "00:00";
}
function normalizeTimestamp(value: unknown): number | null {
const numeric = toNumber(value, Number.NaN);
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}
function normalizeBudgetConfig(config: BudgetConfig): NormalizedBudgetConfig {
return {
dailyLimitUsd: Math.max(0, toNumber(config.dailyLimitUsd)),
weeklyLimitUsd: Math.max(0, toNumber(config.weeklyLimitUsd)),
monthlyLimitUsd: Math.max(0, toNumber(config.monthlyLimitUsd)),
warningThreshold: Math.min(Math.max(toNumber(config.warningThreshold, 0.8), 0), 1),
resetInterval: normalizeResetInterval(config.resetInterval),
resetTime: normalizeResetTime(config.resetTime),
budgetResetAt: normalizeTimestamp(config.budgetResetAt),
lastBudgetResetAt: normalizeTimestamp(config.lastBudgetResetAt),
warningEmittedAt: normalizeTimestamp(config.warningEmittedAt),
warningPeriodStart: normalizeTimestamp(config.warningPeriodStart),
};
}
function getResetTimeParts(resetTime: string): [number, number] {
const match = resetTime.match(RESET_TIME_REGEX);
if (!match) return [0, 0];
return [parseInt(match[1], 10), parseInt(match[2], 10)];
}
function getUtcDateMs(year: number, month: number, day: number, hours: number, minutes: number) {
return Date.UTC(year, month, day, hours, minutes, 0, 0);
}
export function getBudgetWindow(
resetInterval: BudgetResetInterval,
resetTime = "00:00",
now = Date.now()
): BudgetWindow {
const current = new Date(now);
const [hours, minutes] = getResetTimeParts(normalizeResetTime(resetTime));
const year = current.getUTCFullYear();
const month = current.getUTCMonth();
const day = current.getUTCDate();
if (resetInterval === "weekly") {
const daysSinceMonday = (current.getUTCDay() + 6) % 7;
const thisWeekReset = getUtcDateMs(year, month, day - daysSinceMonday, hours, minutes);
return now >= thisWeekReset
? {
periodStartAt: thisWeekReset,
nextResetAt: getUtcDateMs(year, month, day - daysSinceMonday + 7, hours, minutes),
}
: {
periodStartAt: getUtcDateMs(year, month, day - daysSinceMonday - 7, hours, minutes),
nextResetAt: thisWeekReset,
};
}
if (resetInterval === "monthly") {
const thisMonthReset = getUtcDateMs(year, month, 1, hours, minutes);
return now >= thisMonthReset
? {
periodStartAt: thisMonthReset,
nextResetAt: getUtcDateMs(year, month + 1, 1, hours, minutes),
}
: {
periodStartAt: getUtcDateMs(year, month - 1, 1, hours, minutes),
nextResetAt: thisMonthReset,
};
}
const todayReset = getUtcDateMs(year, month, day, hours, minutes);
return now >= todayReset
? {
periodStartAt: todayReset,
nextResetAt: getUtcDateMs(year, month, day + 1, hours, minutes),
}
: {
periodStartAt: getUtcDateMs(year, month, day - 1, hours, minutes),
nextResetAt: todayReset,
};
}
function getActiveBudgetLimit(budget: NormalizedBudgetConfig): number {
if (budget.resetInterval === "monthly") {
return budget.monthlyLimitUsd > 0 ? budget.monthlyLimitUsd : budget.dailyLimitUsd;
}
if (budget.resetInterval === "weekly") {
return budget.weeklyLimitUsd > 0 ? budget.weeklyLimitUsd : budget.dailyLimitUsd;
}
return budget.dailyLimitUsd;
}
function getBudgetWindowTotal(apiKeyId: string, periodStartAt: number): number {
try {
return (
loadCostTotal(apiKeyId, periodStartAt) +
spendBatchWriter.getPendingCostTotal(apiKeyId, periodStartAt)
);
} catch {
return 0;
}
}
function getBudgetWindowRangeTotal(
apiKeyId: string,
periodStartAt: number,
periodEndAt: number
): number {
try {
return (
sumEntries(toCostEntries(loadCostEntriesInRange(apiKeyId, periodStartAt, periodEndAt))) +
spendBatchWriter.getPendingCostTotal(apiKeyId, periodStartAt, periodEndAt)
);
} catch {
return 0;
}
}
function emitBudgetWarning(
apiKeyId: string,
budget: NormalizedBudgetConfig,
projectedTotal: number,
activeLimitUsd: number,
nextResetAt: number
) {
const percentage =
activeLimitUsd > 0 ? ((projectedTotal / activeLimitUsd) * 100).toFixed(1) : "0.0";
console.warn(
`[BudgetWarning] ${apiKeyId} reached ${percentage}% of ${budget.resetInterval} budget ($${projectedTotal.toFixed(4)} / $${activeLimitUsd.toFixed(2)}) — next reset ${new Date(nextResetAt).toISOString()}`
);
}
function syncBudgetSchedule(
apiKeyId: string,
config: BudgetConfig,
now = Date.now(),
options: SyncBudgetScheduleOptions = {}
): NormalizedBudgetConfig {
const normalized = normalizeBudgetConfig(config);
const window = getBudgetWindow(normalized.resetInterval, normalized.resetTime, now);
const resetRolled =
normalized.lastBudgetResetAt !== null && window.periodStartAt > normalized.lastBudgetResetAt;
if (resetRolled && options.logReset !== false) {
const previousSpend = getBudgetWindowRangeTotal(
apiKeyId,
normalized.lastBudgetResetAt as number,
window.periodStartAt
);
try {
saveBudgetResetLog({
apiKeyId,
resetInterval: normalized.resetInterval,
previousSpend,
resetAt: window.periodStartAt,
nextResetAt: window.nextResetAt,
periodStart: window.periodStartAt,
periodEnd: window.nextResetAt,
});
} catch {
// Non-fatal: budget logic still proceeds even if logging fails.
}
}
const updated: NormalizedBudgetConfig = {
...normalized,
budgetResetAt: window.nextResetAt,
lastBudgetResetAt: window.periodStartAt,
warningEmittedAt: resetRolled ? null : normalized.warningEmittedAt,
warningPeriodStart: resetRolled ? null : normalized.warningPeriodStart,
};
const changed =
normalized.budgetResetAt !== updated.budgetResetAt ||
normalized.lastBudgetResetAt !== updated.lastBudgetResetAt ||
normalized.warningEmittedAt !== updated.warningEmittedAt ||
normalized.warningPeriodStart !== updated.warningPeriodStart ||
normalized.dailyLimitUsd !== updated.dailyLimitUsd ||
normalized.weeklyLimitUsd !== updated.weeklyLimitUsd ||
normalized.monthlyLimitUsd !== updated.monthlyLimitUsd ||
normalized.warningThreshold !== updated.warningThreshold ||
normalized.resetInterval !== updated.resetInterval ||
normalized.resetTime !== updated.resetTime;
if (changed && options.persist !== false) {
try {
saveBudget(apiKeyId, updated);
} catch {
// Non-critical: in-memory cache still works.
}
}
budgets.set(apiKeyId, updated);
return updated;
}
/**
* Set budget for an API key.
*
* @param {string} apiKeyId
* @param {BudgetConfig} config
*/
export function setBudget(apiKeyId: string, config: BudgetConfig) {
return syncBudgetSchedule(apiKeyId, config, Date.now(), { logReset: false, persist: true });
}
/**
* Get budget config for an API key.
*
* @param {string} apiKeyId
* @returns {NormalizedBudgetConfig | null}
*/
export function getBudget(apiKeyId: string): NormalizedBudgetConfig | null {
const cached = budgets.get(apiKeyId);
if (cached) {
return syncBudgetSchedule(apiKeyId, cached);
}
try {
const fromDb = loadBudget(apiKeyId) as BudgetConfig | null;
if (fromDb) {
return syncBudgetSchedule(apiKeyId, fromDb);
}
} catch {
// DB may not be ready.
}
return null;
}
/**
* Delete budget config and recorded spend for an API key.
*
* @param {string} apiKeyId
*/
export function deleteBudget(apiKeyId: string) {
budgets.delete(apiKeyId);
discardSpendBatchEntries(apiKeyId);
try {
dbDeleteBudget(apiKeyId);
deleteCostEntries(apiKeyId);
} catch {
// Non-critical.
}
}
/**
* Record a cost for an API key.
*
* @param {string} apiKeyId
* @param {number} cost - Cost in USD
*/
export function recordCost(apiKeyId: string, cost: number): void {
try {
spendBatchWriter.increment(apiKeyId, cost, Date.now());
} catch {
// Non-critical.
}
}
/**
* Sync all budgets against the current clock so overdue resets get persisted.
*/
export function syncAllBudgetSchedules(now = Date.now()) {
let processed = 0;
let resetCount = 0;
try {
const allBudgets = loadAllBudgets();
for (const [apiKeyId, budget] of Object.entries(allBudgets)) {
processed += 1;
const synced = syncBudgetSchedule(apiKeyId, budget, now, { logReset: true, persist: true });
if (budget.lastBudgetResetAt !== synced.lastBudgetResetAt) {
resetCount += 1;
}
}
} catch {
// Non-critical.
}
return { processed, resetCount };
}
/**
* Check if an API key has remaining budget.
*
* @param {string} apiKeyId
* @param {number} [additionalCost=0] - Projected cost to check
* @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean, remaining: number, periodUsed: number, activeLimitUsd: number, resetInterval: BudgetResetInterval | null, resetTime: string | null, budgetResetAt: number | null, lastBudgetResetAt: number | null, periodStartAt: number | null }}
*/
export function checkBudget(apiKeyId: string, additionalCost = 0) {
const budget = getBudget(apiKeyId);
if (!budget) {
return {
allowed: true,
dailyUsed: 0,
dailyLimit: 0,
warningReached: false,
remaining: 0,
periodUsed: 0,
activeLimitUsd: 0,
resetInterval: null,
resetTime: null,
budgetResetAt: null,
lastBudgetResetAt: null,
periodStartAt: null,
};
}
const window = getBudgetWindow(budget.resetInterval, budget.resetTime);
const periodUsed = getBudgetWindowTotal(apiKeyId, window.periodStartAt);
const projectedTotal = periodUsed + additionalCost;
const activeLimitUsd = getActiveBudgetLimit(budget);
const warningReached =
activeLimitUsd > 0 && projectedTotal >= activeLimitUsd * budget.warningThreshold;
const remaining = Math.max(activeLimitUsd - projectedTotal, 0);
if (warningReached && budget.warningPeriodStart !== window.periodStartAt) {
const updatedBudget = {
...budget,
warningEmittedAt: Date.now(),
warningPeriodStart: window.periodStartAt,
};
budgets.set(apiKeyId, updatedBudget);
try {
saveBudget(apiKeyId, updatedBudget);
emitBudgetWarning(
apiKeyId,
updatedBudget,
projectedTotal,
activeLimitUsd,
window.nextResetAt
);
} catch {
// Non-critical.
}
}
if (activeLimitUsd > 0 && projectedTotal > activeLimitUsd) {
return {
allowed: false,
reason: `${budget.resetInterval[0].toUpperCase()}${budget.resetInterval.slice(1)} budget exceeded: $${projectedTotal.toFixed(4)} / $${activeLimitUsd.toFixed(2)}`,
dailyUsed: periodUsed,
dailyLimit: activeLimitUsd,
warningReached: true,
remaining,
periodUsed,
activeLimitUsd,
resetInterval: budget.resetInterval,
resetTime: budget.resetTime,
budgetResetAt: window.nextResetAt,
lastBudgetResetAt: window.periodStartAt,
periodStartAt: window.periodStartAt,
};
}
return {
allowed: true,
dailyUsed: periodUsed,
dailyLimit: activeLimitUsd,
warningReached,
remaining,
periodUsed,
activeLimitUsd,
resetInterval: budget.resetInterval,
resetTime: budget.resetTime,
budgetResetAt: window.nextResetAt,
lastBudgetResetAt: window.periodStartAt,
periodStartAt: window.periodStartAt,
};
}
/**
* Get daily total cost for an API key.
*
* @param {string} apiKeyId
* @returns {number} Total cost today in USD
*/
export function getDailyTotal(apiKeyId: string): number {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
try {
return (
sumEntries(toCostEntries(loadCostEntries(apiKeyId, todayStart.getTime()))) +
spendBatchWriter.getPendingCostTotal(apiKeyId, todayStart.getTime())
);
} catch {
return 0;
}
}
/**
* Get cost summary for an API key.
*
* @param {string} apiKeyId
* @returns {BudgetSummary}
*/
export function getCostSummary(apiKeyId: string): BudgetSummary {
const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const budget = getBudget(apiKeyId);
const window = budget ? getBudgetWindow(budget.resetInterval, budget.resetTime) : null;
try {
const dailyEntries = [
...toCostEntries(loadCostEntries(apiKeyId, todayStart.getTime())),
...spendBatchWriter.getBufferedEntries(apiKeyId, todayStart.getTime()),
];
const monthlyEntries = [
...toCostEntries(loadCostEntries(apiKeyId, monthStart.getTime())),
...spendBatchWriter.getBufferedEntries(apiKeyId, monthStart.getTime()),
];
const periodEntries =
window !== null
? [
...toCostEntries(loadCostEntries(apiKeyId, window.periodStartAt)),
...spendBatchWriter.getBufferedEntries(apiKeyId, window.periodStartAt),
]
: [];
const dailyTotal = sumEntries(dailyEntries);
const monthlyTotal = sumEntries(monthlyEntries);
const periodTotal = sumEntries(periodEntries);
const activeLimitUsd = budget ? getActiveBudgetLimit(budget) : 0;
return {
dailyTotal,
monthlyTotal,
totalEntries: monthlyEntries.length,
budget,
totalCostToday: dailyTotal,
totalCostMonth: monthlyTotal,
totalCostPeriod: periodTotal,
activeLimitUsd,
resetInterval: budget?.resetInterval ?? null,
resetTime: budget?.resetTime ?? null,
budgetResetAt: window?.nextResetAt ?? budget?.budgetResetAt ?? null,
lastBudgetResetAt: window?.periodStartAt ?? budget?.lastBudgetResetAt ?? null,
periodStartAt: window?.periodStartAt ?? null,
nextResetAt: window?.nextResetAt ?? null,
dailyLimitUsd: budget?.dailyLimitUsd ?? 0,
weeklyLimitUsd: budget?.weeklyLimitUsd ?? 0,
monthlyLimitUsd: budget?.monthlyLimitUsd ?? 0,
warningThreshold: budget?.warningThreshold ?? null,
};
} catch {
return {
dailyTotal: 0,
monthlyTotal: 0,
totalEntries: 0,
budget,
totalCostToday: 0,
totalCostMonth: 0,
totalCostPeriod: 0,
activeLimitUsd: budget ? getActiveBudgetLimit(budget) : 0,
resetInterval: budget?.resetInterval ?? null,
resetTime: budget?.resetTime ?? null,
budgetResetAt: budget?.budgetResetAt ?? null,
lastBudgetResetAt: budget?.lastBudgetResetAt ?? null,
periodStartAt: budget?.lastBudgetResetAt ?? null,
nextResetAt: budget?.budgetResetAt ?? null,
dailyLimitUsd: budget?.dailyLimitUsd ?? 0,
weeklyLimitUsd: budget?.weeklyLimitUsd ?? 0,
monthlyLimitUsd: budget?.monthlyLimitUsd ?? 0,
warningThreshold: budget?.warningThreshold ?? null,
};
}
}
/**
* Clear all cost data (for testing).
*/
export function resetCostData() {
budgets.clear();
resetSpendBatchWriterForTests();
try {
deleteAllCostData();
} catch {
// Non-critical.
}
}