// Only import server-only in Next.js environment (not when running with tsx/node directly) if (typeof process !== 'undefined' && process.env.NEXT_RUNTIME) { import('server-only').catch(() => {}); }
import { CloudWatchClient, PutMetricDataCommand, StandardUnit } from '@aws-sdk/client-cloudwatch'; import { DynamoDBClient, GetItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb'; import { PublishCommand, SNSClient } from '@aws-sdk/client-sns';
export type CostLevel = 'ok' | 'warning' | 'critical' | 'exceeded';
export type RuntimeCostState = { monthKey: string; spendUsd: number; turnCount: number; budgetUsd: number; percentUsed: number; remainingUsd: number; level: CostLevel; estimatedTurnsRemaining: number; updatedAt: string; };
type Logger = (event: string, payload: Record<string, unknown>) => void;
export type RuntimeCostClients = { dynamo: DynamoDBClient; cloudwatch: CloudWatchClient; sns?: SNSClient; tableName: string; alertTopicArn?: string; env: string; budgetUsd: number; };
const TTL_GRACE_DAYS = 35; const WARNING_THRESHOLD = 80; const CRITICAL_THRESHOLD = 95;
let cachedClients: { key: string; clients: RuntimeCostClients } | null = null; let configuredBudgetUsd: number | null = null;
function parseNumber(value: unknown): number { if (typeof value === 'number') return value; if (typeof value === 'string') { const parsed = Number.parseFloat(value); if (Number.isFinite(parsed)) return parsed; } return 0; }
function resolveEnv(): string { const env = process.env.APP_ENV ?? process.env.NEXT_PUBLIC_APP_ENV ?? process.env.NODE_ENV ?? 'development'; if (env === 'production') return 'prod'; return env; }
export function setRuntimeCostBudget(budgetUsd?: number | null): void { if (typeof budgetUsd === 'number' && Number.isFinite(budgetUsd) && budgetUsd > 0) { configuredBudgetUsd = budgetUsd; return; } configuredBudgetUsd = null; }
function buildMonthKey(now = new Date()): string {
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
return ${year}-${month};
}
function evaluateCostState( spendUsd: number, turnCount: number, budgetUsd: number, now = new Date() ): RuntimeCostState { const safeBudget = budgetUsd > 0 ? budgetUsd : Number.POSITIVE_INFINITY; const remainingUsd = Math.max(0, safeBudget - spendUsd); const percentUsed = Number.isFinite(safeBudget) ? (spendUsd / safeBudget) * 100 : 0; let level: CostLevel = 'ok'; if (percentUsed >= 100) { level = 'exceeded'; } else if (percentUsed >= CRITICAL_THRESHOLD) { level = 'critical'; } else if (percentUsed >= WARNING_THRESHOLD) { level = 'warning'; } const avgCostPerTurn = turnCount > 0 ? spendUsd / turnCount : 0; const estimatedTurnsRemaining = avgCostPerTurn > 0 ? Math.floor(remainingUsd / avgCostPerTurn) : 0;
return { monthKey: buildMonthKey(now), spendUsd, turnCount, budgetUsd, percentUsed, remainingUsd, level, estimatedTurnsRemaining, updatedAt: now.toISOString(), }; }
function computeTtlSeconds(now = new Date()): number { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); const monthEnd = new Date(Date.UTC(year, month + 1, 0, 23, 59, 59, 999)); const ttlDate = new Date(monthEnd.getTime() + TTL_GRACE_DAYS * 24 * 60 * 60 * 1000); return Math.floor(ttlDate.getTime() / 1000); }
async function publishCostMetrics( clients: RuntimeCostClients, { turnCostUsd, monthTotalUsd, now = new Date() }: { turnCostUsd: number; monthTotalUsd: number; now?: Date } ) { const yearMonth = buildMonthKey(now); await clients.cloudwatch.send( new PutMetricDataCommand({ Namespace: 'PortfolioChat/Costs', MetricData: [ { MetricName: 'RuntimeCostTurnUsd', Value: turnCostUsd, Unit: StandardUnit.None, StorageResolution: 60, Dimensions: [ { Name: 'Env', Value: clients.env }, { Name: 'YearMonth', Value: yearMonth }, ], }, { MetricName: 'RuntimeCostMtdUsd', Value: monthTotalUsd, Unit: StandardUnit.None, StorageResolution: 60, Dimensions: [ { Name: 'Env', Value: clients.env }, { Name: 'YearMonth', Value: yearMonth }, ], }, ], }) ); }
function getEnvKey(env: string): string { return env; }
export async function getRuntimeCostClients(): Promise<RuntimeCostClients | null> { if (!configuredBudgetUsd || configuredBudgetUsd <= 0) { return null; } const tableName = process.env.COST_TABLE_NAME ?? process.env.CHAT_COST_TABLE_NAME; if (!tableName) { return null; }
const alertTopicArn = process.env.COST_ALERT_TOPIC_ARN ?? process.env.CHAT_COST_ALERT_TOPIC_ARN;
const env = resolveEnv(); const cacheKey = [tableName, alertTopicArn, env, configuredBudgetUsd ?? 'none'].join('|'); if (cachedClients?.key === cacheKey) { return cachedClients.clients; }
cachedClients = { key: cacheKey, clients: { dynamo: new DynamoDBClient({}), cloudwatch: new CloudWatchClient({}), sns: alertTopicArn ? new SNSClient({}) : undefined, tableName, alertTopicArn, env, budgetUsd: configuredBudgetUsd, }, }; return cachedClients.clients; }
export async function getRuntimeCostState(clients: RuntimeCostClients): Promise { const now = new Date(); const yearMonth = buildMonthKey(now); const key = { owner_env: { S: getEnvKey(clients.env) }, year_month: { S: yearMonth }, } as const;
const result = await clients.dynamo.send( new GetItemCommand({ TableName: clients.tableName, Key: key, ProjectionExpression: 'monthTotalUsd, turnCount, updatedAt', }) );
const spendUsd = parseNumber(result.Item?.monthTotalUsd?.N ?? 0); const turnCount = Math.max(0, Math.floor(parseNumber(result.Item?.turnCount?.N ?? 0))); return evaluateCostState(spendUsd, turnCount, clients.budgetUsd, now); }
export async function recordRuntimeCost( clients: RuntimeCostClients, costUsd: number, logger?: Logger ): Promise { const previous = await getRuntimeCostState(clients); const increment = Number.isFinite(costUsd) && costUsd > 0 ? costUsd : 0; const now = new Date(); const yearMonth = buildMonthKey(now); const key = { owner_env: { S: getEnvKey(clients.env) }, year_month: { S: yearMonth }, } as const;
const update = await clients.dynamo.send( new UpdateItemCommand({ TableName: clients.tableName, Key: key, UpdateExpression: 'ADD monthTotalUsd :delta, turnCount :one SET updatedAt = :now, expiresAt = :ttl', ExpressionAttributeValues: { ':delta': { N: increment.toFixed(6) }, ':one': { N: '1' }, ':now': { S: now.toISOString() }, ':ttl': { N: computeTtlSeconds(now).toString() }, }, ReturnValues: 'UPDATED_NEW', }) );
const spendUsd = parseNumber(update.Attributes?.monthTotalUsd?.N ?? 0); const turnCount = Math.max(0, Math.floor(parseNumber(update.Attributes?.turnCount?.N ?? 0))); const state = evaluateCostState(spendUsd, turnCount, clients.budgetUsd, now);
try { await publishCostMetrics(clients, { turnCostUsd: increment, monthTotalUsd: spendUsd, now }); } catch (error) { logger?.('chat.cost.metrics_error', { error: String(error) }); }
if (clients.sns && clients.alertTopicArn) {
try {
const levels: CostLevel[] = ['ok', 'warning', 'critical', 'exceeded'];
const levelIncreased = levels.indexOf(state.level) > levels.indexOf(previous.level);
if (levelIncreased && (state.level === 'critical' || state.level === 'exceeded')) {
await clients.sns.send(
new PublishCommand({
TopicArn: clients.alertTopicArn,
Subject: Chat runtime cost ${state.level},
Message: JSON.stringify({
env: clients.env,
level: state.level,
spendUsd: state.spendUsd,
budgetUsd: state.budgetUsd,
percentUsed: state.percentUsed,
estimatedTurnsRemaining: state.estimatedTurnsRemaining,
updatedAt: state.updatedAt,
}),
})
);
}
} catch (error) {
logger?.('chat.cost.alert_error', { error: String(error) });
}
}
return state; }
export async function shouldThrottleForBudget(clients: RuntimeCostClients, logger?: Logger): Promise { try { const state = await getRuntimeCostState(clients); if (state.level === 'critical' || state.level === 'exceeded') { logger?.('chat.cost.budget_block', { level: state.level, spendUsd: state.spendUsd, budgetUsd: state.budgetUsd, percentUsed: state.percentUsed, }); } return state; } catch (error) { logger?.('chat.cost.budget_check_error', { error: String(error) }); return evaluateCostState(0, 0, clients.budgetUsd); } }
