feat(bd-hn5): Budget Alerts and Projections
Add comprehensive budget monitoring and alert system: - Burn rate calculation (cost per minute over configurable window) - Time-to-budget-exhaustion projection with formatted output - Top consumer identification with cost breakdown and insights - Real-time alert generation at 80% (warning) and 95% (critical) thresholds - BudgetAlertPanel TUI component for displaying alerts and projections - New interfaces: BurnRate, BudgetAlert, TopConsumer - Formatting utilities: formatBurnRate, formatTimeToExhaustion, getBudgetBadge, formatBudgetAlert Thresholds updated per plan.md specification (80%/95% instead of 75%/90%). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c59e016b67
commit
e1f81e5bed
3 changed files with 846 additions and 7 deletions
397
src/tui/components/BudgetAlertPanel.ts
Normal file
397
src/tui/components/BudgetAlertPanel.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* Budget Alert Panel Component
|
||||
*
|
||||
* Displays budget alerts, burn rate, and cost projections.
|
||||
* Shows warnings at 80% and critical alerts at 95% budget consumed.
|
||||
*/
|
||||
|
||||
import blessed from 'blessed';
|
||||
import {
|
||||
CostSummary,
|
||||
BudgetStatus,
|
||||
BurnRate,
|
||||
BudgetAlert,
|
||||
TopConsumer,
|
||||
formatCost,
|
||||
formatBurnRate,
|
||||
formatTimeToExhaustion,
|
||||
getBudgetBadge,
|
||||
} from '../utils/costTracking.js';
|
||||
import { colors } from '../utils/colors.js';
|
||||
|
||||
export interface BudgetAlertPanelOptions {
|
||||
/** Parent screen */
|
||||
parent: blessed.Widgets.Screen;
|
||||
|
||||
/** Position options */
|
||||
top: number | string;
|
||||
left: number | string;
|
||||
width: number | string;
|
||||
height: number | string;
|
||||
|
||||
/** Callback when alert is acknowledged */
|
||||
onAcknowledge?: (alertId: string) => void;
|
||||
|
||||
/** Callback when budget settings are opened */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export class BudgetAlertPanel {
|
||||
private container: blessed.Widgets.BoxElement;
|
||||
private headerBox: blessed.Widgets.BoxElement;
|
||||
private contentBox: blessed.Widgets.BoxElement;
|
||||
private footerBox: blessed.Widgets.BoxElement;
|
||||
private costSummary: CostSummary | null = null;
|
||||
private alerts: BudgetAlert[] = [];
|
||||
private onAcknowledge?: (alertId: string) => void;
|
||||
private onOpenSettings?: () => void;
|
||||
|
||||
constructor(options: BudgetAlertPanelOptions) {
|
||||
this.onAcknowledge = options.onAcknowledge;
|
||||
this.onOpenSettings = options.onOpenSettings;
|
||||
|
||||
// Main container
|
||||
this.container = blessed.box({
|
||||
parent: options.parent,
|
||||
top: options.top,
|
||||
left: options.left,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
label: ' Budget Dashboard ',
|
||||
border: { type: 'line' },
|
||||
style: {
|
||||
border: { fg: colors.border },
|
||||
label: { fg: colors.header },
|
||||
},
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
// Header with current cost and budget
|
||||
this.headerBox = blessed.box({
|
||||
parent: this.container,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
content: '{gray-fg}No budget data{/}',
|
||||
tags: true,
|
||||
});
|
||||
|
||||
// Content area with alerts and details
|
||||
this.contentBox = blessed.box({
|
||||
parent: this.container,
|
||||
top: 3,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
keys: true,
|
||||
vi: true,
|
||||
tags: true,
|
||||
style: {
|
||||
fg: colors.text,
|
||||
},
|
||||
});
|
||||
|
||||
// Footer with controls
|
||||
this.footerBox = blessed.box({
|
||||
parent: this.container,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 1,
|
||||
content: ' [a] Acknowledge [r] Refresh [s] Settings [Esc] Close',
|
||||
style: {
|
||||
fg: colors.muted,
|
||||
},
|
||||
});
|
||||
|
||||
// Bind keyboard events
|
||||
this.bindKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind keyboard shortcuts
|
||||
*/
|
||||
private bindKeys(): void {
|
||||
this.contentBox.key(['a'], () => this.acknowledgeCurrentAlert());
|
||||
this.contentBox.key(['r'], () => this.refresh());
|
||||
this.contentBox.key(['s'], () => {
|
||||
if (this.onOpenSettings) {
|
||||
this.onOpenSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cost summary data
|
||||
*/
|
||||
setCostSummary(summary: CostSummary): void {
|
||||
this.costSummary = summary;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alerts
|
||||
*/
|
||||
setAlerts(alerts: BudgetAlert[]): void {
|
||||
this.alerts = alerts;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh display
|
||||
*/
|
||||
refresh(): void {
|
||||
this.render();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge current/most recent alert
|
||||
*/
|
||||
private acknowledgeCurrentAlert(): void {
|
||||
const unacknowledged = this.alerts.filter(a => !a.acknowledged);
|
||||
if (unacknowledged.length > 0 && this.onAcknowledge) {
|
||||
this.onAcknowledge(unacknowledged[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the panel
|
||||
*/
|
||||
private render(): void {
|
||||
this.renderHeader();
|
||||
this.renderContent();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render header with budget status
|
||||
*/
|
||||
private renderHeader(): void {
|
||||
if (!this.costSummary) {
|
||||
this.headerBox.setContent('{gray-fg}No budget data loaded{/}');
|
||||
return;
|
||||
}
|
||||
|
||||
const { budget, totalCostUsd, burnRate } = this.costSummary;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Budget progress bar
|
||||
if (budget.limit > 0) {
|
||||
const percent = Math.min(100, budget.percentUsed);
|
||||
const filled = Math.floor(percent / 5); // 20 segments
|
||||
const empty = 20 - filled;
|
||||
|
||||
let barColor = 'green';
|
||||
if (budget.warningLevel === 'critical') barColor = 'red';
|
||||
else if (budget.warningLevel === 'warning') barColor = 'yellow';
|
||||
|
||||
const bar = `{${barColor}-fg}${'█'.repeat(filled)}{/}{gray-fg}${'░'.repeat(empty)}{/}`;
|
||||
|
||||
lines.push(` ${formatCost(totalCostUsd)} / ${formatCost(budget.limit)} ${bar} ${Math.round(percent)}%`);
|
||||
} else {
|
||||
lines.push(` Session Cost: {green-fg}${formatCost(totalCostUsd)}{/} {gray-fg}(no budget set){/}`);
|
||||
}
|
||||
|
||||
// Burn rate line
|
||||
if (burnRate.costPerMinute > 0) {
|
||||
const burnRateColor = burnRate.isHighBurnRate ? 'yellow' : 'green';
|
||||
lines.push(` Rate: {${burnRateColor}-fg}${formatBurnRate(burnRate.costPerMinute)}{/}`);
|
||||
|
||||
if (burnRate.timeToExhaustion && budget.limit > 0) {
|
||||
lines[1] += ` Time to exhaustion: ${burnRate.timeToExhaustion}`;
|
||||
}
|
||||
}
|
||||
|
||||
this.headerBox.setContent(lines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content area
|
||||
*/
|
||||
private renderContent(): void {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Show active alerts first
|
||||
const activeAlerts = this.alerts.filter(a => !a.acknowledged);
|
||||
if (activeAlerts.length > 0) {
|
||||
lines.push(this.renderAlertsSection(activeAlerts));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Show top consumers
|
||||
if (this.costSummary) {
|
||||
lines.push(this.renderTopConsumersSection(this.costSummary));
|
||||
}
|
||||
|
||||
// Show burn rate details
|
||||
if (this.costSummary?.burnRate) {
|
||||
lines.push('');
|
||||
lines.push(this.renderBurnRateSection(this.costSummary.burnRate));
|
||||
}
|
||||
|
||||
this.contentBox.setContent(lines.join('\n'));
|
||||
this.contentBox.setScrollPerc(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render alerts section
|
||||
*/
|
||||
private renderAlertsSection(alerts: BudgetAlert[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{red-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{red-fg} ACTIVE ALERTS ({/}' + alerts.length + '{bold}{red-fg}){/}');
|
||||
lines.push('{bold}{red-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
for (const alert of alerts) {
|
||||
const icon = alert.type === 'exhausted' ? '🚨' :
|
||||
alert.type === 'critical' ? '⚠️' : '⚡';
|
||||
const color = alert.type === 'exhausted' || alert.type === 'critical' ? 'red' : 'yellow';
|
||||
|
||||
lines.push(`{${color}-fg}${icon} ${alert.type.toUpperCase()}{/} {gray-fg}${new Date(alert.timestamp).toLocaleTimeString()}{/}`);
|
||||
lines.push(` Spent: ${formatCost(alert.spent)} / ${formatCost(alert.limit)}`);
|
||||
lines.push(` Burn rate: ${formatBurnRate(alert.burnRate)}`);
|
||||
lines.push('');
|
||||
|
||||
if (alert.topConsumers.length > 0) {
|
||||
lines.push(' {bold}Top consumers:{/}');
|
||||
for (const consumer of alert.topConsumers) {
|
||||
const beadInfo = consumer.currentBead ? ` {cyan-fg}(${consumer.currentBead}){/}` : '';
|
||||
const insightInfo = consumer.insight ? ` {gray-fg}- ${consumer.insight}{/}` : '';
|
||||
lines.push(` ${consumer.workerId}${beadInfo}: {yellow-fg}${formatCost(consumer.costUsd)}{/}${insightInfo}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render top consumers section
|
||||
*/
|
||||
private renderTopConsumersSection(summary: CostSummary): string {
|
||||
const lines: string[] = [];
|
||||
const workers = Array.from(summary.byWorker.values())
|
||||
.sort((a, b) => b.costUsd - a.costUsd)
|
||||
.slice(0, 5);
|
||||
|
||||
if (workers.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{cyan-fg} TOP CONSUMERS{/}');
|
||||
lines.push('{bold}{cyan-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
const totalCost = summary.totalCostUsd;
|
||||
|
||||
for (const worker of workers) {
|
||||
const percent = totalCost > 0 ? Math.round((worker.costUsd / totalCost) * 100) : 0;
|
||||
const beadInfo = worker.currentBead ? ` {cyan-fg}(${worker.currentBead}){/}` : '';
|
||||
|
||||
// Create mini progress bar
|
||||
const barWidth = 10;
|
||||
const filled = Math.floor((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
|
||||
lines.push(`{bold}${worker.workerId}{/}${beadInfo}`);
|
||||
lines.push(` {yellow-fg}${formatCost(worker.costUsd)}{/} {gray-fg}[${bar}] ${percent}%{/}`);
|
||||
lines.push(` {gray-fg}~${Math.round(worker.total / 1000)}k tokens, ${worker.apiCalls} calls{/}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render burn rate section
|
||||
*/
|
||||
private renderBurnRateSection(burnRate: BurnRate): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('{bold}{magenta-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('{bold}{magenta-fg} BURN RATE ANALYSIS{/}');
|
||||
lines.push('{bold}{magenta-fg}══════════════════════════════════════════════════{/}');
|
||||
lines.push('');
|
||||
|
||||
const rateColor = burnRate.isHighBurnRate ? 'yellow' : 'green';
|
||||
lines.push(`Current rate: {${rateColor}-fg}${formatBurnRate(burnRate.costPerMinute)}{/}`);
|
||||
lines.push(`Window: ${burnRate.windowMinutes} minutes`);
|
||||
|
||||
if (burnRate.timeToExhaustion) {
|
||||
lines.push(`Time to exhaustion: {cyan-fg}${burnRate.timeToExhaustion}{/}`);
|
||||
}
|
||||
|
||||
lines.push(`Projected session total: {yellow-fg}${formatCost(burnRate.projectedTotalCost)}{/}`);
|
||||
|
||||
if (burnRate.isHighBurnRate) {
|
||||
lines.push('');
|
||||
lines.push('{yellow-fg}⚠ High burn rate detected!{/}');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the panel
|
||||
*/
|
||||
show(): void {
|
||||
this.container.show();
|
||||
this.contentBox.focus();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the panel
|
||||
*/
|
||||
hide(): void {
|
||||
this.container.hide();
|
||||
this.container.screen.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility
|
||||
*/
|
||||
toggle(): void {
|
||||
if (this.container.hidden) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if visible
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return !this.container.hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus this component
|
||||
*/
|
||||
focus(): void {
|
||||
this.contentBox.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying blessed element
|
||||
*/
|
||||
getElement(): blessed.Widgets.BoxElement {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a budget alert panel
|
||||
*/
|
||||
export function createBudgetAlertPanel(options: BudgetAlertPanelOptions): BudgetAlertPanel {
|
||||
return new BudgetAlertPanel(options);
|
||||
}
|
||||
|
|
@ -51,3 +51,9 @@ export type { SemanticNarrativePanelOptions } from './SemanticNarrativePanel.js'
|
|||
|
||||
export { WorkerAnalyticsPanel } from './WorkerAnalyticsPanel.js';
|
||||
export type { WorkerAnalyticsPanelOptions } from './WorkerAnalyticsPanel.js';
|
||||
|
||||
export { FileContextPanel } from './FileContextPanel.js';
|
||||
export type { FileContextPanelOptions, FileContext, FileOperation } from './FileContextPanel.js';
|
||||
|
||||
export { BudgetAlertPanel, createBudgetAlertPanel } from './BudgetAlertPanel.js';
|
||||
export type { BudgetAlertPanelOptions } from './BudgetAlertPanel.js';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* Tracks token usage from log events and calculates estimated costs.
|
||||
* Displays total tokens, estimated cost, and per-worker breakdown.
|
||||
* Includes burn rate calculations, budget alerts, and projections.
|
||||
*/
|
||||
|
||||
import { LogEvent } from '../../types.js';
|
||||
|
|
@ -27,6 +28,12 @@ export interface WorkerCost extends TokenUsage {
|
|||
|
||||
/** Number of API calls */
|
||||
apiCalls: number;
|
||||
|
||||
/** Current bead being worked on (if any) */
|
||||
currentBead?: string;
|
||||
|
||||
/** Last activity timestamp */
|
||||
lastActivityTs?: number;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
|
|
@ -42,6 +49,9 @@ export interface CostSummary {
|
|||
/** Budget status */
|
||||
budget: BudgetStatus;
|
||||
|
||||
/** Burn rate information */
|
||||
burnRate: BurnRate;
|
||||
|
||||
/** Time range of data */
|
||||
timeRange: {
|
||||
start: number;
|
||||
|
|
@ -64,16 +74,85 @@ export interface BudgetStatus {
|
|||
|
||||
/** Warning level (none, warning, critical) */
|
||||
warningLevel: 'none' | 'warning' | 'critical';
|
||||
|
||||
/** Remaining budget in USD */
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface BurnRate {
|
||||
/** Cost per minute in USD */
|
||||
costPerMinute: number;
|
||||
|
||||
/** Time in minutes until budget exhausted (null if no budget or zero burn rate) */
|
||||
minutesToExhaustion: number | null;
|
||||
|
||||
/** Formatted time to exhaustion (e.g., "2h 30m", "45m", "< 1m") */
|
||||
timeToExhaustion: string | null;
|
||||
|
||||
/** Projected total cost at current burn rate (if session continues) */
|
||||
projectedTotalCost: number;
|
||||
|
||||
/** Window size used for burn rate calculation in minutes */
|
||||
windowMinutes: number;
|
||||
|
||||
/** Whether burn rate is high (> 0.50/min) */
|
||||
isHighBurnRate: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetAlert {
|
||||
/** Unique alert ID */
|
||||
id: string;
|
||||
|
||||
/** Alert type */
|
||||
type: 'warning' | 'critical' | 'exhausted';
|
||||
|
||||
/** Alert message */
|
||||
message: string;
|
||||
|
||||
/** Timestamp when alert was generated */
|
||||
timestamp: number;
|
||||
|
||||
/** Current spend at time of alert */
|
||||
spent: number;
|
||||
|
||||
/** Budget limit */
|
||||
limit: number;
|
||||
|
||||
/** Burn rate at time of alert */
|
||||
burnRate: number;
|
||||
|
||||
/** Top consumers at time of alert */
|
||||
topConsumers: TopConsumer[];
|
||||
|
||||
/** Whether alert has been acknowledged */
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface TopConsumer {
|
||||
/** Worker ID */
|
||||
workerId: string;
|
||||
|
||||
/** Cost in USD */
|
||||
costUsd: number;
|
||||
|
||||
/** Percentage of total cost */
|
||||
percentOfTotal: number;
|
||||
|
||||
/** Current bead (if known) */
|
||||
currentBead?: string;
|
||||
|
||||
/** Reason for high consumption (optional insight) */
|
||||
insight?: string;
|
||||
}
|
||||
|
||||
export interface CostTrackingOptions {
|
||||
/** Budget limit in USD (0 = no limit) */
|
||||
budgetLimit?: number;
|
||||
|
||||
/** Warning threshold (percent, default 75) */
|
||||
/** Warning threshold (percent, default 80 per plan.md) */
|
||||
warningThreshold?: number;
|
||||
|
||||
/** Critical threshold (percent, default 90) */
|
||||
/** Critical threshold (percent, default 95 per plan.md) */
|
||||
criticalThreshold?: number;
|
||||
|
||||
/** Input cost per 1M tokens (default: $3 for Claude) */
|
||||
|
|
@ -81,14 +160,22 @@ export interface CostTrackingOptions {
|
|||
|
||||
/** Output cost per 1M tokens (default: $15 for Claude) */
|
||||
outputCostPerMillion?: number;
|
||||
|
||||
/** Burn rate window in minutes (default: 5) */
|
||||
burnRateWindowMinutes?: number;
|
||||
|
||||
/** High burn rate threshold in USD/min (default: 0.50) */
|
||||
highBurnRateThreshold?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<CostTrackingOptions> = {
|
||||
budgetLimit: 0,
|
||||
warningThreshold: 75,
|
||||
criticalThreshold: 90,
|
||||
warningThreshold: 80, // Per plan.md: warning at 80%
|
||||
criticalThreshold: 95, // Per plan.md: critical at 95%
|
||||
inputCostPerMillion: 3.00, // Claude Sonnet input
|
||||
outputCostPerMillion: 15.00, // Claude Sonnet output
|
||||
burnRateWindowMinutes: 5, // Calculate burn rate over last 5 minutes
|
||||
highBurnRateThreshold: 0.50, // High burn rate if > $0.50/min
|
||||
};
|
||||
|
||||
// Model pricing (per 1M tokens)
|
||||
|
|
@ -114,6 +201,13 @@ export class CostTracker {
|
|||
private firstEventTs: number | null = null;
|
||||
private lastEventTs: number | null = null;
|
||||
|
||||
// Burn rate tracking
|
||||
private costHistory: Array<{ ts: number; cost: number; worker: string }> = [];
|
||||
|
||||
// Alert tracking
|
||||
private alerts: BudgetAlert[] = [];
|
||||
private lastWarningLevel: 'none' | 'warning' | 'critical' = 'none';
|
||||
|
||||
constructor(options: CostTrackingOptions = {}) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
|
@ -144,6 +238,8 @@ export class CostTracker {
|
|||
total: 0,
|
||||
costUsd: 0,
|
||||
apiCalls: 0,
|
||||
currentBead: event.bead,
|
||||
lastActivityTs: event.ts,
|
||||
};
|
||||
this.workerCosts.set(event.worker, workerCost);
|
||||
}
|
||||
|
|
@ -153,13 +249,33 @@ export class CostTracker {
|
|||
workerCost.output += tokens.output;
|
||||
workerCost.total += tokens.input + tokens.output;
|
||||
workerCost.apiCalls += 1;
|
||||
workerCost.lastActivityTs = event.ts;
|
||||
if (event.bead) {
|
||||
workerCost.currentBead = event.bead;
|
||||
}
|
||||
|
||||
// Calculate cost based on model
|
||||
const model = (event.model as string) || 'claude-sonnet-4-6';
|
||||
const pricing = MODEL_PRICING[model] || MODEL_PRICING['claude-sonnet-4-6'];
|
||||
workerCost.costUsd =
|
||||
(workerCost.input * pricing.input / 1_000_000) +
|
||||
(workerCost.output * pricing.output / 1_000_000);
|
||||
const incrementalCost =
|
||||
(tokens.input * pricing.input / 1_000_000) +
|
||||
(tokens.output * pricing.output / 1_000_000);
|
||||
|
||||
workerCost.costUsd += incrementalCost;
|
||||
|
||||
// Track cost history for burn rate calculation
|
||||
this.costHistory.push({
|
||||
ts: event.ts,
|
||||
cost: incrementalCost,
|
||||
worker: event.worker,
|
||||
});
|
||||
|
||||
// Trim old history (keep last 30 minutes)
|
||||
const cutoffTs = event.ts - (30 * 60 * 1000);
|
||||
this.costHistory = this.costHistory.filter(h => h.ts > cutoffTs);
|
||||
|
||||
// Check for budget alerts
|
||||
this.checkBudgetAlerts();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,6 +332,7 @@ export class CostTracker {
|
|||
(totalOutput * totalPrice.output / 1_000_000);
|
||||
|
||||
const budget = this.calculateBudgetStatus(totalCostUsd);
|
||||
const burnRate = this.calculateBurnRate();
|
||||
|
||||
return {
|
||||
total: {
|
||||
|
|
@ -226,6 +343,7 @@ export class CostTracker {
|
|||
totalCostUsd,
|
||||
byWorker: new Map(this.workerCosts),
|
||||
budget,
|
||||
burnRate,
|
||||
timeRange: {
|
||||
start: this.firstEventTs || Date.now(),
|
||||
end: this.lastEventTs || Date.now(),
|
||||
|
|
@ -233,6 +351,227 @@ export class CostTracker {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate burn rate (cost per minute)
|
||||
*/
|
||||
private calculateBurnRate(): BurnRate {
|
||||
const now = this.lastEventTs || Date.now();
|
||||
const windowMs = this.options.burnRateWindowMinutes * 60 * 1000;
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
// Get costs within the burn rate window
|
||||
const recentCosts = this.costHistory.filter(h => h.ts >= windowStart);
|
||||
const totalCostInWindow = recentCosts.reduce((sum, h) => sum + h.cost, 0);
|
||||
|
||||
// Calculate actual window duration (may be less if we don't have enough history)
|
||||
const oldestInWindow = recentCosts.length > 0
|
||||
? Math.min(...recentCosts.map(h => h.ts))
|
||||
: windowStart;
|
||||
const actualWindowMs = now - oldestInWindow;
|
||||
const actualWindowMinutes = actualWindowMs / 60000;
|
||||
|
||||
// Cost per minute
|
||||
const costPerMinute = actualWindowMinutes > 0
|
||||
? totalCostInWindow / actualWindowMinutes
|
||||
: 0;
|
||||
|
||||
// Calculate total cost directly (avoid recursion with getSummary)
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
for (const worker of this.workerCosts.values()) {
|
||||
totalInput += worker.input;
|
||||
totalOutput += worker.output;
|
||||
}
|
||||
const totalPrice = MODEL_PRICING['claude-sonnet-4-6'];
|
||||
const currentTotalCost =
|
||||
(totalInput * totalPrice.input / 1_000_000) +
|
||||
(totalOutput * totalPrice.output / 1_000_000);
|
||||
|
||||
// Calculate time to exhaustion
|
||||
let minutesToExhaustion: number | null = null;
|
||||
let timeToExhaustion: string | null = null;
|
||||
|
||||
if (this.options.budgetLimit > 0 && costPerMinute > 0) {
|
||||
const remaining = this.options.budgetLimit - currentTotalCost;
|
||||
if (remaining > 0) {
|
||||
minutesToExhaustion = remaining / costPerMinute;
|
||||
timeToExhaustion = formatTimeToExhaustion(minutesToExhaustion);
|
||||
} else {
|
||||
minutesToExhaustion = 0;
|
||||
timeToExhaustion = 'exhausted';
|
||||
}
|
||||
}
|
||||
|
||||
// Projected total cost at current burn rate for remainder of session
|
||||
// Assume 60-minute session by default if we don't have enough data
|
||||
const sessionDurationMs = (this.lastEventTs || now) - (this.firstEventTs || now);
|
||||
const sessionMinutes = sessionDurationMs / 60000;
|
||||
const projectedTotalCost = sessionMinutes > 0
|
||||
? currentTotalCost + (costPerMinute * Math.max(0, 60 - sessionMinutes))
|
||||
: costPerMinute * 60;
|
||||
|
||||
return {
|
||||
costPerMinute,
|
||||
minutesToExhaustion,
|
||||
timeToExhaustion,
|
||||
projectedTotalCost,
|
||||
windowMinutes: this.options.burnRateWindowMinutes,
|
||||
isHighBurnRate: costPerMinute > this.options.highBurnRateThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top consumers by cost
|
||||
*/
|
||||
getTopConsumers(limit: number = 5): TopConsumer[] {
|
||||
const totalCost = Array.from(this.workerCosts.values())
|
||||
.reduce((sum, w) => sum + w.costUsd, 0);
|
||||
|
||||
if (totalCost === 0) return [];
|
||||
|
||||
const consumers = Array.from(this.workerCosts.values())
|
||||
.sort((a, b) => b.costUsd - a.costUsd)
|
||||
.slice(0, limit)
|
||||
.map(w => ({
|
||||
workerId: w.workerId,
|
||||
costUsd: w.costUsd,
|
||||
percentOfTotal: (w.costUsd / totalCost) * 100,
|
||||
currentBead: w.currentBead,
|
||||
insight: this.getWorkerInsight(w),
|
||||
}));
|
||||
|
||||
return consumers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insight about worker's cost pattern
|
||||
*/
|
||||
private getWorkerInsight(worker: WorkerCost): string | undefined {
|
||||
// Check for high API call count
|
||||
if (worker.apiCalls > 100) {
|
||||
return 'high API call volume';
|
||||
}
|
||||
|
||||
// Check for high output ratio (expensive)
|
||||
const outputRatio = worker.total > 0 ? worker.output / worker.total : 0;
|
||||
if (outputRatio > 0.4) {
|
||||
return 'high output token ratio';
|
||||
}
|
||||
|
||||
// Check for rapid recent activity
|
||||
const recentCosts = this.costHistory.filter(h => h.worker === worker.workerId);
|
||||
if (recentCosts.length > 20) {
|
||||
return 'high activity rate';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check budget thresholds and generate alerts
|
||||
*/
|
||||
private checkBudgetAlerts(): void {
|
||||
if (this.options.budgetLimit === 0) return;
|
||||
|
||||
const summary = this.getSummary();
|
||||
const { warningLevel, spent, limit } = summary.budget;
|
||||
|
||||
// Only generate alert if warning level changed
|
||||
if (warningLevel !== this.lastWarningLevel && warningLevel !== 'none') {
|
||||
const alert: BudgetAlert = {
|
||||
id: `alert-${Date.now()}`,
|
||||
type: warningLevel === 'critical' ? 'critical' : 'warning',
|
||||
message: this.generateAlertMessage(warningLevel, spent, limit, summary.burnRate),
|
||||
timestamp: Date.now(),
|
||||
spent,
|
||||
limit,
|
||||
burnRate: summary.burnRate.costPerMinute,
|
||||
topConsumers: this.getTopConsumers(3),
|
||||
acknowledged: false,
|
||||
};
|
||||
|
||||
this.alerts.push(alert);
|
||||
this.lastWarningLevel = warningLevel;
|
||||
}
|
||||
|
||||
// Check for budget exhaustion
|
||||
if (summary.budget.isOverBudget && this.lastWarningLevel !== 'critical') {
|
||||
const alert: BudgetAlert = {
|
||||
id: `alert-${Date.now()}`,
|
||||
type: 'exhausted',
|
||||
message: `Budget exhausted! Spent $${spent.toFixed(2)} of $${limit.toFixed(2)} budget.`,
|
||||
timestamp: Date.now(),
|
||||
spent,
|
||||
limit,
|
||||
burnRate: summary.burnRate.costPerMinute,
|
||||
topConsumers: this.getTopConsumers(3),
|
||||
acknowledged: false,
|
||||
};
|
||||
|
||||
this.alerts.push(alert);
|
||||
this.lastWarningLevel = 'critical';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate alert message
|
||||
*/
|
||||
private generateAlertMessage(
|
||||
level: 'warning' | 'critical',
|
||||
spent: number,
|
||||
limit: number,
|
||||
burnRate: BurnRate
|
||||
): string {
|
||||
const percent = Math.round((spent / limit) * 100);
|
||||
const icon = level === 'critical' ? '🚨' : '⚠️';
|
||||
const label = level === 'critical' ? 'CRITICAL' : 'WARNING';
|
||||
|
||||
let message = `${icon} BUDGET ${label}\n\n`;
|
||||
message += `Daily budget ${percent}% consumed ($${spent.toFixed(2)} / $${limit.toFixed(2)})\n`;
|
||||
|
||||
if (burnRate.costPerMinute > 0) {
|
||||
message += `Current burn rate: $${burnRate.costPerMinute.toFixed(2)}/min\n`;
|
||||
|
||||
if (burnRate.timeToExhaustion) {
|
||||
message += `Time until budget exhausted: ${burnRate.timeToExhaustion}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return message.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active alerts
|
||||
*/
|
||||
getAlerts(): BudgetAlert[] {
|
||||
return this.alerts.filter(a => !a.acknowledged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all alerts including acknowledged
|
||||
*/
|
||||
getAllAlerts(): BudgetAlert[] {
|
||||
return [...this.alerts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge an alert
|
||||
*/
|
||||
acknowledgeAlert(alertId: string): void {
|
||||
const alert = this.alerts.find(a => a.id === alertId);
|
||||
if (alert) {
|
||||
alert.acknowledged = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts
|
||||
*/
|
||||
clearAlerts(): void {
|
||||
this.alerts = [];
|
||||
this.lastWarningLevel = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate budget status
|
||||
*/
|
||||
|
|
@ -246,11 +585,13 @@ export class CostTracker {
|
|||
percentUsed: 0,
|
||||
isOverBudget: false,
|
||||
warningLevel: 'none',
|
||||
remaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const percentUsed = (spent / limit) * 100;
|
||||
const isOverBudget = spent > limit;
|
||||
const remaining = Math.max(0, limit - spent);
|
||||
|
||||
let warningLevel: 'none' | 'warning' | 'critical' = 'none';
|
||||
if (percentUsed >= this.options.criticalThreshold || isOverBudget) {
|
||||
|
|
@ -265,6 +606,7 @@ export class CostTracker {
|
|||
percentUsed,
|
||||
isOverBudget,
|
||||
warningLevel,
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -273,8 +615,11 @@ export class CostTracker {
|
|||
*/
|
||||
reset(): void {
|
||||
this.workerCosts.clear();
|
||||
this.costHistory = [];
|
||||
this.alerts = [];
|
||||
this.firstEventTs = null;
|
||||
this.lastEventTs = null;
|
||||
this.lastWarningLevel = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -282,6 +627,16 @@ export class CostTracker {
|
|||
*/
|
||||
setBudgetLimit(limit: number): void {
|
||||
this.options.budgetLimit = limit;
|
||||
// Re-check alerts with new limit
|
||||
this.checkBudgetAlerts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost history for the specified time window
|
||||
*/
|
||||
getCostHistory(sinceMinutes: number = 30): Array<{ ts: number; cost: number; worker: string }> {
|
||||
const cutoffTs = (this.lastEventTs || Date.now()) - (sinceMinutes * 60 * 1000);
|
||||
return this.costHistory.filter(h => h.ts >= cutoffTs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +669,34 @@ export function formatTokens(count: number): string {
|
|||
return `${(count / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time to exhaustion
|
||||
*/
|
||||
export function formatTimeToExhaustion(minutes: number): string {
|
||||
if (minutes < 1) {
|
||||
return '< 1m';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `~${Math.round(minutes)}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.round(minutes % 60);
|
||||
if (mins === 0) {
|
||||
return `~${hours}h`;
|
||||
}
|
||||
return `~${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format burn rate for display
|
||||
*/
|
||||
export function formatBurnRate(costPerMinute: number): string {
|
||||
if (costPerMinute < 0.01) {
|
||||
return `$${(costPerMinute * 100).toFixed(2)}c/min`;
|
||||
}
|
||||
return `$${costPerMinute.toFixed(2)}/min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget indicator character
|
||||
*/
|
||||
|
|
@ -329,6 +712,59 @@ export function getBudgetIndicator(status: BudgetStatus): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget status badge text
|
||||
*/
|
||||
export function getBudgetBadge(status: BudgetStatus): string {
|
||||
if (status.limit === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const percent = Math.round(status.percentUsed);
|
||||
const icon = getBudgetIndicator(status);
|
||||
|
||||
if (status.isOverBudget) {
|
||||
return `${icon} OVER BUDGET`;
|
||||
}
|
||||
|
||||
if (status.warningLevel === 'critical') {
|
||||
return `${icon} ${percent}% CRITICAL`;
|
||||
}
|
||||
|
||||
if (status.warningLevel === 'warning') {
|
||||
return `${icon} ${percent}%`;
|
||||
}
|
||||
|
||||
return `${percent}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format budget alert for display
|
||||
*/
|
||||
export function formatBudgetAlert(alert: BudgetAlert): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const icon = alert.type === 'exhausted' ? '🚨' :
|
||||
alert.type === 'critical' ? '⚠️' : '⚡';
|
||||
|
||||
lines.push(`${icon} BUDGET ${alert.type.toUpperCase()}`);
|
||||
lines.push('');
|
||||
lines.push(`Spent: $${alert.spent.toFixed(2)} / $${alert.limit.toFixed(2)}`);
|
||||
lines.push(`Burn rate: ${formatBurnRate(alert.burnRate)}`);
|
||||
lines.push('');
|
||||
|
||||
if (alert.topConsumers.length > 0) {
|
||||
lines.push('Top consumers:');
|
||||
for (const consumer of alert.topConsumers) {
|
||||
const beadInfo = consumer.currentBead ? ` (${consumer.currentBead})` : '';
|
||||
const insightInfo = consumer.insight ? ` - ${consumer.insight}` : '';
|
||||
lines.push(` ${consumer.workerId}${beadInfo}: ${formatCost(consumer.costUsd)}${insightInfo}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a global cost tracker instance
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue