mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
feat(cli): add burn guardrails
This commit is contained in:
parent
869474b3b4
commit
660675a051
5 changed files with 561 additions and 0 deletions
|
|
@ -3,6 +3,10 @@
|
|||
## Unreleased
|
||||
|
||||
### Added (CLI)
|
||||
- **Burn Guard.** New `codeburn guard` command warns when AI coding spend
|
||||
reaches configurable per-session or per-local-hour USD thresholds. Supports
|
||||
period/date range/provider/project filters, `--json` for scripts, and
|
||||
`--fail-on-alert` for cron/CI guardrail workflows.
|
||||
- **MCP tool coverage detector.** New `optimize` finding flags MCP servers
|
||||
whose tool inventory is largely unused. Inventory is observed from the
|
||||
Claude `deferred_tools_delta` JSONL attachments (exact tool names per
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -78,6 +78,8 @@ codeburn status # compact one-liner (today + month)
|
|||
codeburn status --format json
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
codeburn guard # warn when today's spend crosses guardrails
|
||||
codeburn guard --max-session-usd 3 --max-hourly-usd 10
|
||||
codeburn optimize # find waste, get copy-paste fixes
|
||||
codeburn optimize -p week # scope the scan to last 7 days
|
||||
codeburn compare # side-by-side model comparison
|
||||
|
|
@ -138,6 +140,18 @@ Adding a new provider is a single file. See `src/providers/codex.ts` for an exam
|
|||
|
||||
Prices every API call using input, output, cache read, cache write, and web search token counts. Fast mode multiplier for Claude. Pricing fetched from [LiteLLM](https://github.com/BerriAI/litellm) and cached locally for 24 hours. Hardcoded fallbacks for all Claude and GPT models to prevent mispricing.
|
||||
|
||||
### Burn Guard
|
||||
|
||||
Run a quick local guardrail check before spend becomes a surprise:
|
||||
|
||||
```bash
|
||||
codeburn guard
|
||||
codeburn guard -p week --max-session-usd 5 --max-hourly-usd 20
|
||||
codeburn guard --json --fail-on-alert
|
||||
```
|
||||
|
||||
The guard command flags sessions or local-hour windows that reach configurable USD thresholds. It supports the same provider, project, exclude, period, `--from`, and `--to` filters as the reporting commands, so it can be used manually, from cron, or in shell scripts.
|
||||
|
||||
### Task Categories
|
||||
|
||||
13 categories classified from tool usage patterns and user message keywords. No LLM calls, fully deterministic.
|
||||
|
|
|
|||
66
src/cli.ts
66
src/cli.ts
|
|
@ -14,6 +14,7 @@ import { renderDashboard } from './dashboard.js'
|
|||
import { parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { analyzeGuard, DEFAULT_MAX_HOURLY_USD, DEFAULT_MAX_SESSION_USD, renderGuardText } from './guard.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
|
||||
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
|
|
@ -48,6 +49,13 @@ function parseInteger(value: string): number {
|
|||
return parseInt(value, 10)
|
||||
}
|
||||
|
||||
function validatePositiveNumber(name: string, value: number): boolean {
|
||||
if (Number.isFinite(value) && value > 0) return true
|
||||
console.error(`\n ${name} must be a positive number; got ${value}.\n`)
|
||||
process.exitCode = 1
|
||||
return false
|
||||
}
|
||||
|
||||
type JsonPlanSummary = {
|
||||
id: PlanId
|
||||
budget: number
|
||||
|
|
@ -572,6 +580,64 @@ program
|
|||
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('guard')
|
||||
.description('Warn when AI coding spend crosses session or hourly guardrails')
|
||||
.option('-p, --period <period>', 'Guard period: today, week, 30days, month, all', 'today')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.option('--max-session-usd <amount>', 'Alert when one session is at or above this USD cost', parseNumber, DEFAULT_MAX_SESSION_USD)
|
||||
.option('--max-hourly-usd <amount>', 'Alert when one local hour is at or above this USD cost', parseNumber, DEFAULT_MAX_HOURLY_USD)
|
||||
.option('--json', 'Print machine-readable JSON')
|
||||
.option('--fail-on-alert', 'Exit with status 1 when guard alerts are found')
|
||||
.action(async (opts) => {
|
||||
const sessionThresholdOk = validatePositiveNumber('--max-session-usd', opts.maxSessionUsd)
|
||||
const hourlyThresholdOk = validatePositiveNumber('--max-hourly-usd', opts.maxHourlyUsd)
|
||||
if (!sessionThresholdOk || !hourlyThresholdOk) {
|
||||
return
|
||||
}
|
||||
|
||||
let customRange: DateRange | null = null
|
||||
try {
|
||||
customRange = parseDateRangeFlags(opts.from, opts.to)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`\n Error: ${message}\n`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
await loadPricing()
|
||||
|
||||
const period = toPeriod(opts.period)
|
||||
const { range, label } = customRange
|
||||
? { range: customRange, label: `${toDateString(customRange.start)} to ${toDateString(customRange.end)}` }
|
||||
: getDateRange(period)
|
||||
const projects = filterProjectsByName(
|
||||
await parseAllSessions(range, opts.provider),
|
||||
opts.project,
|
||||
opts.exclude,
|
||||
)
|
||||
const result = analyzeGuard(projects, {
|
||||
label,
|
||||
maxSessionUSD: opts.maxSessionUsd,
|
||||
maxHourlyUSD: opts.maxHourlyUsd,
|
||||
})
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
} else {
|
||||
console.log(renderGuardText(result))
|
||||
}
|
||||
|
||||
if (opts.failOnAlert && result.alerts.length > 0) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
|
||||
program
|
||||
.command('menubar')
|
||||
.description('Install and launch the macOS menubar app (one command, no clone)')
|
||||
|
|
|
|||
253
src/guard.ts
Normal file
253
src/guard.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import chalk from 'chalk'
|
||||
|
||||
import { formatCost } from './currency.js'
|
||||
import { toDateString } from './daily-cache.js'
|
||||
import type { ProjectSummary } from './types.js'
|
||||
|
||||
export const DEFAULT_MAX_SESSION_USD = 3
|
||||
export const DEFAULT_MAX_HOURLY_USD = 10
|
||||
|
||||
const PANEL_WIDTH = 72
|
||||
const SEP = '-'
|
||||
const RED = '#ff6b6b'
|
||||
const GREEN = '#7bd88f'
|
||||
const GOLD = '#ffd166'
|
||||
const ORANGE = '#ff9f1c'
|
||||
const DIM = '#6b7280'
|
||||
|
||||
export type GuardThresholds = {
|
||||
maxSessionUSD: number
|
||||
maxHourlyUSD: number
|
||||
}
|
||||
|
||||
export type GuardSessionAlert = {
|
||||
type: 'session'
|
||||
project: string
|
||||
sessionId: string
|
||||
costUSD: number
|
||||
limitUSD: number
|
||||
apiCalls: number
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
}
|
||||
|
||||
export type GuardHourlyAlert = {
|
||||
type: 'hour'
|
||||
hour: string
|
||||
costUSD: number
|
||||
limitUSD: number
|
||||
apiCalls: number
|
||||
projects: string[]
|
||||
sessions: string[]
|
||||
}
|
||||
|
||||
export type GuardAlert = GuardSessionAlert | GuardHourlyAlert
|
||||
|
||||
export type GuardSummary = {
|
||||
projects: number
|
||||
sessions: number
|
||||
apiCalls: number
|
||||
totalCostUSD: number
|
||||
}
|
||||
|
||||
export type GuardResult = {
|
||||
label: string
|
||||
thresholds: GuardThresholds
|
||||
summary: GuardSummary
|
||||
alerts: GuardAlert[]
|
||||
}
|
||||
|
||||
export type AnalyzeGuardOptions = {
|
||||
label: string
|
||||
maxSessionUSD: number
|
||||
maxHourlyUSD: number
|
||||
}
|
||||
|
||||
type HourBucket = {
|
||||
costUSD: number
|
||||
apiCalls: number
|
||||
projects: Set<string>
|
||||
sessions: Set<string>
|
||||
}
|
||||
|
||||
function parseTimestamp(timestamp: string | undefined): Date | null {
|
||||
if (!timestamp) return null
|
||||
const date = new Date(timestamp)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function localHourLabel(timestamp: string | undefined): string | null {
|
||||
const date = parseTimestamp(timestamp)
|
||||
if (!date) return null
|
||||
return `${toDateString(date)} ${String(date.getHours()).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
function isPositiveCost(costUSD: number): boolean {
|
||||
return Number.isFinite(costUSD) && costUSD > 0
|
||||
}
|
||||
|
||||
function severity(alert: GuardAlert): number {
|
||||
return alert.limitUSD > 0 ? alert.costUSD / alert.limitUSD : alert.costUSD
|
||||
}
|
||||
|
||||
export function analyzeGuard(projects: ProjectSummary[], options: AnalyzeGuardOptions): GuardResult {
|
||||
const thresholds: GuardThresholds = {
|
||||
maxSessionUSD: options.maxSessionUSD,
|
||||
maxHourlyUSD: options.maxHourlyUSD,
|
||||
}
|
||||
|
||||
const alerts: GuardAlert[] = []
|
||||
const hourly = new Map<string, HourBucket>()
|
||||
let sessions = 0
|
||||
let apiCalls = 0
|
||||
let totalCostUSD = 0
|
||||
|
||||
for (const project of projects) {
|
||||
totalCostUSD += project.totalCostUSD
|
||||
apiCalls += project.totalApiCalls
|
||||
sessions += project.sessions.length
|
||||
|
||||
for (const session of project.sessions) {
|
||||
if (session.totalCostUSD >= thresholds.maxSessionUSD) {
|
||||
alerts.push({
|
||||
type: 'session',
|
||||
project: project.project,
|
||||
sessionId: session.sessionId,
|
||||
costUSD: session.totalCostUSD,
|
||||
limitUSD: thresholds.maxSessionUSD,
|
||||
apiCalls: session.apiCalls,
|
||||
firstTimestamp: session.firstTimestamp,
|
||||
lastTimestamp: session.lastTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
if (!isPositiveCost(call.costUSD)) continue
|
||||
const hour = localHourLabel(call.timestamp) ?? localHourLabel(turn.timestamp)
|
||||
if (!hour) continue
|
||||
|
||||
const bucket = hourly.get(hour) ?? {
|
||||
costUSD: 0,
|
||||
apiCalls: 0,
|
||||
projects: new Set<string>(),
|
||||
sessions: new Set<string>(),
|
||||
}
|
||||
bucket.costUSD += call.costUSD
|
||||
bucket.apiCalls += 1
|
||||
bucket.projects.add(project.project)
|
||||
bucket.sessions.add(session.sessionId)
|
||||
hourly.set(hour, bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hour, bucket] of hourly) {
|
||||
if (bucket.costUSD < thresholds.maxHourlyUSD) continue
|
||||
alerts.push({
|
||||
type: 'hour',
|
||||
hour,
|
||||
costUSD: bucket.costUSD,
|
||||
limitUSD: thresholds.maxHourlyUSD,
|
||||
apiCalls: bucket.apiCalls,
|
||||
projects: [...bucket.projects].sort(),
|
||||
sessions: [...bucket.sessions].sort(),
|
||||
})
|
||||
}
|
||||
|
||||
alerts.sort((a, b) => {
|
||||
const bySeverity = severity(b) - severity(a)
|
||||
if (bySeverity !== 0) return bySeverity
|
||||
return b.costUSD - a.costUSD
|
||||
})
|
||||
|
||||
return {
|
||||
label: options.label,
|
||||
thresholds,
|
||||
summary: {
|
||||
projects: projects.length,
|
||||
sessions,
|
||||
apiCalls,
|
||||
totalCostUSD,
|
||||
},
|
||||
alerts,
|
||||
}
|
||||
}
|
||||
|
||||
function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
|
||||
return `${count} ${count === 1 ? singular : pluralForm}`
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value
|
||||
return `${value.slice(0, Math.max(0, maxLength - 3))}...`
|
||||
}
|
||||
|
||||
function renderAlertLine(alert: GuardAlert): string {
|
||||
if (alert.type === 'session') {
|
||||
const target = truncate(`${alert.project}/${alert.sessionId}`, 42)
|
||||
return [
|
||||
chalk.hex(RED)('Session'),
|
||||
chalk.bold(target),
|
||||
chalk.hex(GOLD)(formatCost(alert.costUSD)),
|
||||
chalk.dim(`limit ${formatCost(alert.limitUSD)}`),
|
||||
chalk.dim(`${plural(alert.apiCalls, 'call')}`),
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
const projects = alert.projects.length > 0 ? alert.projects.join(', ') : 'unknown project'
|
||||
return [
|
||||
chalk.hex(RED)('Hour'),
|
||||
chalk.bold(alert.hour),
|
||||
chalk.hex(GOLD)(formatCost(alert.costUSD)),
|
||||
chalk.dim(`limit ${formatCost(alert.limitUSD)}`),
|
||||
chalk.dim(`${plural(alert.apiCalls, 'call')} across ${truncate(projects, 30)}`),
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
export function renderGuardText(result: GuardResult): string {
|
||||
const lines: string[] = []
|
||||
lines.push('')
|
||||
lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn guard')}${chalk.dim(' ' + result.label)}`)
|
||||
lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH)))
|
||||
lines.push(' ' + [
|
||||
plural(result.summary.projects, 'project'),
|
||||
plural(result.summary.sessions, 'session'),
|
||||
plural(result.summary.apiCalls, 'call'),
|
||||
chalk.hex(GOLD)(formatCost(result.summary.totalCostUSD)),
|
||||
].join(chalk.hex(DIM)(' ')))
|
||||
lines.push(' ' + chalk.dim([
|
||||
`session limit ${formatCost(result.thresholds.maxSessionUSD)}`,
|
||||
`hourly limit ${formatCost(result.thresholds.maxHourlyUSD)}`,
|
||||
].join(' ')))
|
||||
lines.push('')
|
||||
|
||||
if (result.summary.sessions === 0) {
|
||||
lines.push(chalk.dim(' No usage data found for this period.'))
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
if (result.alerts.length === 0) {
|
||||
lines.push(chalk.hex(GREEN)(' Guard OK. No spend guardrails crossed.'))
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines.push(chalk.hex(RED)(` ${plural(result.alerts.length, 'alert')} crossed configured guardrails.`))
|
||||
lines.push('')
|
||||
|
||||
const visibleAlerts = result.alerts.slice(0, 10)
|
||||
for (const alert of visibleAlerts) {
|
||||
lines.push(` ${renderAlertLine(alert)}`)
|
||||
}
|
||||
|
||||
const hidden = result.alerts.length - visibleAlerts.length
|
||||
if (hidden > 0) {
|
||||
lines.push(chalk.dim(` ...and ${plural(hidden, 'more alert')}.`))
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
224
tests/guard.test.ts
Normal file
224
tests/guard.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { analyzeGuard, renderGuardText } from '../src/guard.js'
|
||||
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
function makeCall(costUSD: number, timestamp: string): ParsedApiCall {
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
costUSD,
|
||||
tools: [],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
timestamp,
|
||||
bashCommands: [],
|
||||
deduplicationKey: `${timestamp}:${costUSD}`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTurn(sessionId: string, timestamp: string, calls: ParsedApiCall[]): ClassifiedTurn {
|
||||
return {
|
||||
userMessage: 'test',
|
||||
assistantCalls: calls,
|
||||
timestamp,
|
||||
sessionId,
|
||||
category: 'coding',
|
||||
retries: 0,
|
||||
hasEdits: true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
project: string,
|
||||
sessionId: string,
|
||||
totalCostUSD: number,
|
||||
timestamp: string,
|
||||
turns: ClassifiedTurn[] = [],
|
||||
): SessionSummary {
|
||||
const apiCalls = turns.reduce((sum, turn) => sum + turn.assistantCalls.length, 0)
|
||||
return {
|
||||
sessionId,
|
||||
project,
|
||||
firstTimestamp: timestamp,
|
||||
lastTimestamp: timestamp,
|
||||
totalCostUSD,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls,
|
||||
turns,
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(project: string, sessions: SessionSummary[]): ProjectSummary {
|
||||
return {
|
||||
project,
|
||||
projectPath: `/tmp/${project}`,
|
||||
sessions,
|
||||
totalCostUSD: sessions.reduce((sum, session) => sum + session.totalCostUSD, 0),
|
||||
totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0),
|
||||
}
|
||||
}
|
||||
|
||||
describe('analyzeGuard', () => {
|
||||
it('returns no alerts when spend stays below configured guardrails', () => {
|
||||
const result = analyzeGuard([
|
||||
makeProject('app', [
|
||||
makeSession('app', 's1', 1.25, '2026-05-05T10:00:00Z', [
|
||||
makeTurn('s1', '2026-05-05T10:00:00Z', [makeCall(1.25, '2026-05-05T10:00:00Z')]),
|
||||
]),
|
||||
]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 3,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
projects: 1,
|
||||
sessions: 1,
|
||||
apiCalls: 1,
|
||||
totalCostUSD: 1.25,
|
||||
})
|
||||
expect(result.alerts).toEqual([])
|
||||
})
|
||||
|
||||
it('flags sessions that reach the max session threshold', () => {
|
||||
const result = analyzeGuard([
|
||||
makeProject('api', [
|
||||
makeSession('api', 'expensive-session', 3, '2026-05-05T11:00:00Z'),
|
||||
]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 3,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
expect(result.alerts).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'session',
|
||||
project: 'api',
|
||||
sessionId: 'expensive-session',
|
||||
costUSD: 3,
|
||||
limitUSD: 3,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('aggregates hourly spend across sessions and projects', () => {
|
||||
const sessionA = makeSession('api', 's1', 4, '2026-05-05T10:05:00Z', [
|
||||
makeTurn('s1', '2026-05-05T10:05:00Z', [makeCall(4, '2026-05-05T10:05:00Z')]),
|
||||
])
|
||||
const sessionB = makeSession('web', 's2', 6, '2026-05-05T10:05:30Z', [
|
||||
makeTurn('s2', '2026-05-05T10:05:30Z', [makeCall(6, '2026-05-05T10:05:30Z')]),
|
||||
])
|
||||
|
||||
const result = analyzeGuard([
|
||||
makeProject('api', [sessionA]),
|
||||
makeProject('web', [sessionB]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 20,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
expect(result.alerts).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'hour',
|
||||
costUSD: 10,
|
||||
limitUSD: 10,
|
||||
apiCalls: 2,
|
||||
projects: ['api', 'web'],
|
||||
sessions: ['s1', 's2'],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores invalid timestamps for hourly guardrails', () => {
|
||||
const result = analyzeGuard([
|
||||
makeProject('api', [
|
||||
makeSession('api', 's1', 20, '2026-05-05T10:00:00Z', [
|
||||
makeTurn('s1', 'not-a-date', [makeCall(20, 'not-a-date')]),
|
||||
]),
|
||||
]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 100,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
expect(result.alerts).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts alerts by threshold severity before raw cost', () => {
|
||||
const expensiveSession = makeSession('api', 's1', 12, '2026-05-05T09:00:00Z')
|
||||
const hourlySessionA = makeSession('web', 's2', 4, '2026-05-05T10:05:00Z', [
|
||||
makeTurn('s2', '2026-05-05T10:05:00Z', [makeCall(4, '2026-05-05T10:05:00Z')]),
|
||||
])
|
||||
const hourlySessionB = makeSession('web', 's3', 5, '2026-05-05T10:05:30Z', [
|
||||
makeTurn('s3', '2026-05-05T10:05:30Z', [makeCall(5, '2026-05-05T10:05:30Z')]),
|
||||
])
|
||||
|
||||
const result = analyzeGuard([
|
||||
makeProject('api', [expensiveSession]),
|
||||
makeProject('web', [hourlySessionA, hourlySessionB]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 6,
|
||||
maxHourlyUSD: 8,
|
||||
})
|
||||
|
||||
expect(result.alerts).toHaveLength(2)
|
||||
expect(result.alerts[0]).toMatchObject({ type: 'session', sessionId: 's1', costUSD: 12 })
|
||||
expect(result.alerts[1]).toMatchObject({ type: 'hour', costUSD: 9 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderGuardText', () => {
|
||||
it('shows an empty-period message when no sessions are present', () => {
|
||||
const result = analyzeGuard([], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 3,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
expect(renderGuardText(result)).toContain('No usage data found for this period.')
|
||||
})
|
||||
|
||||
it('renders session alerts with the target and limit', () => {
|
||||
const result = analyzeGuard([
|
||||
makeProject('api', [
|
||||
makeSession('api', 'expensive-session', 3, '2026-05-05T11:00:00Z'),
|
||||
]),
|
||||
], {
|
||||
label: 'Today',
|
||||
maxSessionUSD: 3,
|
||||
maxHourlyUSD: 10,
|
||||
})
|
||||
|
||||
const text = renderGuardText(result)
|
||||
expect(text).toContain('Session')
|
||||
expect(text).toContain('api/expensive-session')
|
||||
expect(text).toContain('limit $3.00')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue