diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 00000000..61455c1c --- /dev/null +++ b/BUILD.md @@ -0,0 +1,96 @@ +# Building and Installing ccusage Locally + +This guide explains how to build and install a custom version of ccusage for local development and testing. + +## Prerequisites + +- [Bun](https://bun.sh/) runtime (latest version) +- Node.js (for global installation via npm) +- Git (for version control) + +## Quick Start + +```bash +# 1. Clone and navigate to the project +git clone +cd ccusage + +# 2. Install dependencies +bun install + +# 3. Build the project +bun run build + +# 4. Install globally with custom name +npm install -g . +``` + +## Build Scripts + +The project includes these build-related scripts: + +```bash +# Core build commands +bun run build # Build distribution files +bun run typecheck # Type check TypeScript +bun run format # Format and lint code +bun run test # Run test suite + +# Quality assurance +bun run release # Full release workflow (lint + typecheck + test + build) + +# Development +bun run start # Run from source +``` + +## Troubleshooting + +### Build Fails + +```bash +# Clean and rebuild +rm -rf dist/ node_modules/ +bun install +bun run build +``` + +### Global Install Fails + +```bash +# Try with npm instead of bun +npm install -g . + +# Or try with sudo (macOS/Linux) +sudo npm install -g . +``` + +### Command Not Found + +```bash +# Check if it's in your PATH +which your-custom-name + +# Check npm global packages +npm list -g --depth=0 + +# Verify npm global bin directory +npm config get prefix +``` + +### Permission Issues + +```bash +# Fix npm permissions (macOS/Linux) +mkdir ~/.npm-global +npm config set prefix '~/.npm-global' + +# Add to ~/.bashrc or ~/.zshrc +export PATH=~/.npm-global/bin:$PATH +``` + +## New Features + +- **Cost-based live monitoring**: `--cost-limit` option with numeric values or `max` +- **Model filtering**: `--model opus` or `--model opus,sonnet` for specific models +- **Per-model cost limits**: Historical maximums calculated per model +- **Improved projections**: Activity-aware calculations for sparse model usage diff --git a/bun.lock b/bun.lock index 2525adee..15db811d 100644 --- a/bun.lock +++ b/bun.lock @@ -3,6 +3,10 @@ "workspaces": { "": { "name": "ccusage", + "dependencies": { + "hg-ccusage": ".", + "g": "^2.0.1", + }, "devDependencies": { "@antfu/utils": "^9.2.0", "@core/errorutil": "npm:@jsr/core__errorutil", @@ -1071,6 +1075,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "g": ["g@2.0.1", "", {}, "sha512-Fi6Ng5fZ/ANLQ15H11hCe+09sgUoNvDEBevVgx3KoYOhsH5iLNPn54hx0jPZ+3oSWr+xajnp2Qau9VmPsc7hTA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], @@ -1117,6 +1123,8 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hg-ccusage": ["hg-ccusage@file:", { "devDependencies": { "@antfu/utils": "^9.2.0", "@core/errorutil": "npm:@jsr/core__errorutil", "@hono/mcp": "^0.1.0", "@hono/node-server": "^1.14.4", "@jsr/std__async": "1", "@modelcontextprotocol/sdk": "^1.13.0", "@oxc-project/runtime": "^0.73.2", "@ryoppippi/eslint-config": "^0.3.7", "@types/bun": "^1.2.16", "@typescript/native-preview": "^7.0.0-dev.20250619.1", "ansi-escapes": "^7.0.0", "bumpp": "^10.2.0", "clean-pkg-json": "^1.3.0", "cli-table3": "^0.6.5", "consola": "^3.4.2", "es-toolkit": "^1.39.3", "eslint": "^9.29.0", "eslint-plugin-format": "^1.0.1", "fast-sort": "^3.4.1", "fs-fixture": "^2.8.1", "gunshi": "^0.26.3", "hono": "^4.8.0", "lint-staged": "^16.1.2", "path-type": "^6.0.0", "picocolors": "^1.1.1", "pretty-ms": "^9.2.0", "publint": "^0.3.12", "rollup-plugin-node-externals": "^8.0.1", "simple-git-hooks": "^2.13.0", "sort-package-json": "^3.2.1", "string-width": "^7.2.0", "tinyglobby": "^0.2.14", "tsdown": "^0.12.8", "type-fest": "^4.41.0", "unplugin-macros": "^0.17.0", "unplugin-unused": "^0.5.1", "vitest": "^3.2.4", "xdg-basedir": "^5.1.0", "zod": "^3.25.67" }, "bin": "./dist/index.js" }], + "hono": ["hono@4.8.2", "", {}, "sha512-hM+1RIn9PK1I6SiTNS6/y7O1mvg88awYLFEuEtoiMtRyT3SD2iu9pSFgbBXT3b1Ua4IwzvSTLvwO0SEhDxCi4w=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], diff --git a/package.json b/package.json index d7a44b97..24dbd526 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ccusage", + "name": "hg-ccusage", "type": "module", "version": "15.2.0", "description": "Usage analysis tool for Claude Code", @@ -51,6 +51,10 @@ "test": "vitest", "typecheck": "tsgo --noEmit" }, + "dependencies": { + "g": "^2.0.1", + "hg-ccusage": "." + }, "devDependencies": { "@antfu/utils": "^9.2.0", "@core/errorutil": "npm:@jsr/core__errorutil", diff --git a/src/_live-monitor.ts b/src/_live-monitor.ts index 985c3745..67fae809 100644 --- a/src/_live-monitor.ts +++ b/src/_live-monitor.ts @@ -32,6 +32,7 @@ export type LiveMonitorConfig = { sessionDurationHours: number; mode: CostMode; order: SortOrder; + models?: string[]; }; /** @@ -145,7 +146,7 @@ export class LiveMonitor implements Disposable { } } - // Generate blocks and find active one + // Generate blocks from ALL entries first (preserve session structure) const blocks = identifySessionBlocks( this.allEntries, this.config.sessionDurationHours, @@ -156,8 +157,84 @@ export class LiveMonitor implements Disposable { ? blocks : blocks.reverse(); - // Find active block - return sortedBlocks.find(block => block.isActive) ?? null; + // Find active block based on session structure (not filtered data) + const activeBlock = sortedBlocks.find(block => block.isActive); + + if (activeBlock == null) { + return null; + } + + // Apply model filtering to the active block's entries only + if (this.config.models != null && this.config.models.length > 0) { + return this.filterBlockByModels(activeBlock, this.config.models); + } + + return activeBlock; + } + + /** + * Filters a session block's entries by specified models while preserving block structure + * @param block - Original session block + * @param models - Array of model names to filter by + * @returns New session block with filtered entries and recalculated aggregates + */ + private filterBlockByModels(block: SessionBlock, models: string[]): SessionBlock { + // Filter entries by models with partial matching + const filteredEntries = block.entries.filter((entry) => { + const entryModel = entry.model.toLowerCase(); + return models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + return entryModel.includes(lowerFilterModel) || lowerFilterModel.includes(entryModel); + }); + }); + + // If no entries match, return empty block with preserved structure + if (filteredEntries.length === 0) { + return { + ...block, + entries: [], + tokenCounts: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + costUSD: 0, + models: [], + }; + } + + // Recalculate aggregated data for filtered entries + const tokenCounts = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; + + let totalCost = 0; + const uniqueModels = new Set(); + + for (const entry of filteredEntries) { + tokenCounts.inputTokens += entry.usage.inputTokens; + tokenCounts.outputTokens += entry.usage.outputTokens; + tokenCounts.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + tokenCounts.cacheReadInputTokens += entry.usage.cacheReadInputTokens; + + if (entry.costUSD != null) { + totalCost += entry.costUSD; + } + + uniqueModels.add(entry.model); + } + + return { + ...block, + entries: filteredEntries, + tokenCounts, + costUSD: totalCost, + models: Array.from(uniqueModels).sort(), + }; } /** diff --git a/src/_live-rendering.ts b/src/_live-rendering.ts index 71b2b53e..350ae0a4 100644 --- a/src/_live-rendering.ts +++ b/src/_live-rendering.ts @@ -24,10 +24,12 @@ import { formatCurrency, formatModelsDisplay, formatNumber } from './_utils.ts'; export type LiveMonitoringConfig = { claudePath: string; tokenLimit?: number; + costLimit?: number; refreshInterval: number; sessionDurationHours: number; mode: CostMode; order: SortOrder; + models?: string[]; }; /** @@ -136,7 +138,7 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock // Draw header terminal.write(`${marginStr}┌${'─'.repeat(boxWidth - 2)}┐\n`); - terminal.write(`${marginStr}│${pc.bold(centerText('CLAUDE CODE - LIVE TOKEN USAGE MONITOR', boxWidth - 2))}│\n`); + terminal.write(`${marginStr}│${pc.bold(centerText('CLAUDE CODE - LIVE USAGE MONITOR', boxWidth - 2))}│\n`); terminal.write(`${marginStr}├${'─'.repeat(boxWidth - 2)}┤\n`); terminal.write(`${marginStr}│${' '.repeat(boxWidth - 2)}│\n`); @@ -164,25 +166,30 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock terminal.write(`${marginStr}├${'─'.repeat(boxWidth - 2)}┤\n`); terminal.write(`${marginStr}│${' '.repeat(boxWidth - 2)}│\n`); - // Usage section (always show) - const tokenPercent = config.tokenLimit != null && config.tokenLimit > 0 - ? (totalTokens / config.tokenLimit) * 100 - : 0; + // Usage section (always show) - support both token and cost limits + const usingCostLimit = config.costLimit != null && config.costLimit > 0; + const usingTokenLimit = config.tokenLimit != null && config.tokenLimit > 0; + + const usagePercent = usingCostLimit && config.costLimit != null + ? (block.costUSD / config.costLimit) * 100 + : usingTokenLimit && config.tokenLimit != null + ? (totalTokens / config.tokenLimit) * 100 + : 0; // Determine bar color based on percentage let barColor = pc.green; - if (tokenPercent > 100) { + if (usagePercent > 100) { barColor = pc.red; } - else if (tokenPercent > 80) { + else if (usagePercent > 80) { barColor = pc.yellow; } // Create colored progress bar - const usageBar = config.tokenLimit != null && config.tokenLimit > 0 + const usageBar = usingCostLimit && config.costLimit != null ? createProgressBar( - totalTokens, - config.tokenLimit, + block.costUSD, + config.costLimit, barWidth, { showPercentage: false, @@ -192,7 +199,20 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock rightBracket: ']', }, ) - : `[${pc.green('█'.repeat(Math.floor(barWidth * 0.1)))}${pc.gray('░'.repeat(barWidth - Math.floor(barWidth * 0.1)))}]`; + : usingTokenLimit && config.tokenLimit != null + ? createProgressBar( + totalTokens, + config.tokenLimit, + barWidth, + { + showPercentage: false, + fillChar: barColor('█'), + emptyChar: pc.gray('░'), + leftBracket: '[', + rightBracket: ']', + }, + ) + : `[${pc.green('█'.repeat(Math.floor(barWidth * 0.1)))}${pc.gray('░'.repeat(barWidth - Math.floor(barWidth * 0.1)))}]`; // Burn rate with better formatting const burnRate = calculateBurnRate(block); @@ -207,22 +227,29 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock const usageLabel = pc.bold('🔥 USAGE'); const usageLabelWidth = stringWidth(usageLabel); - // Prepare usage bar string and details based on token limit availability + // Prepare usage bar string and details based on limit type availability // Using const destructuring pattern instead of let/reassignment to avoid side effects // This creates immutable values based on the condition, improving code clarity - const { usageBarStr, usageCol1, usageCol2, usageCol3 } = config.tokenLimit != null && config.tokenLimit > 0 + const { usageBarStr, usageCol1, usageCol2, usageCol3 } = usingCostLimit && config.costLimit != null ? { - usageBarStr: `${usageLabel}${''.padEnd(Math.max(0, labelWidth - usageLabelWidth))} ${usageBar} ${tokenPercent.toFixed(1).padStart(6)}% (${formatTokensShort(totalTokens)}/${formatTokensShort(config.tokenLimit)})`, - usageCol1: `${pc.gray('Tokens:')} ${formatNumber(totalTokens)} (${rateDisplay})`, - usageCol2: `${pc.gray('Limit:')} ${formatNumber(config.tokenLimit)} tokens`, - usageCol3: `${pc.gray('Cost:')} ${formatCurrency(block.costUSD)}`, + usageBarStr: `${usageLabel}${''.padEnd(Math.max(0, labelWidth - usageLabelWidth))} ${usageBar} ${usagePercent.toFixed(1).padStart(6)}% (${formatCurrency(block.costUSD)}/${formatCurrency(config.costLimit)})`, + usageCol1: `${pc.gray('Cost:')} ${formatCurrency(block.costUSD)} (${burnRate?.costPerHour != null ? `$${burnRate.costPerHour.toFixed(2)}/hour` : 'N/A'})`, + usageCol2: `${pc.gray('Limit:')} ${formatCurrency(config.costLimit)}`, + usageCol3: `${pc.gray('Tokens:')} ${formatNumber(totalTokens)}`, } - : { - usageBarStr: `${usageLabel}${''.padEnd(Math.max(0, labelWidth - usageLabelWidth))} ${usageBar} (${formatTokensShort(totalTokens)} tokens)`, - usageCol1: `${pc.gray('Tokens:')} ${formatNumber(totalTokens)} (${rateDisplay})`, - usageCol2: '', - usageCol3: `${pc.gray('Cost:')} ${formatCurrency(block.costUSD)}`, - }; + : usingTokenLimit && config.tokenLimit != null + ? { + usageBarStr: `${usageLabel}${''.padEnd(Math.max(0, labelWidth - usageLabelWidth))} ${usageBar} ${usagePercent.toFixed(1).padStart(6)}% (${formatTokensShort(totalTokens)}/${formatTokensShort(config.tokenLimit)})`, + usageCol1: `${pc.gray('Tokens:')} ${formatNumber(totalTokens)} (${rateDisplay})`, + usageCol2: `${pc.gray('Limit:')} ${formatNumber(config.tokenLimit)} tokens`, + usageCol3: `${pc.gray('Cost:')} ${formatCurrency(block.costUSD)}`, + } + : { + usageBarStr: `${usageLabel}${''.padEnd(Math.max(0, labelWidth - usageLabelWidth))} ${usageBar} (${formatTokensShort(totalTokens)} tokens)`, + usageCol1: `${pc.gray('Tokens:')} ${formatNumber(totalTokens)} (${rateDisplay})`, + usageCol2: '', + usageCol3: `${pc.gray('Cost:')} ${formatCurrency(block.costUSD)}`, + }; // Render usage bar const usageBarPadded = usageBarStr + ' '.repeat(Math.max(0, boxWidth - 3 - stringWidth(usageBarStr))); @@ -244,9 +271,11 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock // Projections section const projection = projectBlockUsage(block); if (projection != null) { - const projectedPercent = config.tokenLimit != null && config.tokenLimit > 0 - ? (projection.totalTokens / config.tokenLimit) * 100 - : 0; + const projectedPercent = usingCostLimit && config.costLimit != null + ? (projection.totalCost / config.costLimit) * 100 + : usingTokenLimit && config.tokenLimit != null + ? (projection.totalTokens / config.tokenLimit) * 100 + : 0; // Determine projection bar color let projBarColor = pc.green; @@ -258,10 +287,10 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock } // Create projection bar - const projectionBar = config.tokenLimit != null && config.tokenLimit > 0 + const projectionBar = usingCostLimit && config.costLimit != null ? createProgressBar( - projection.totalTokens, - config.tokenLimit, + projection.totalCost, + config.costLimit, barWidth, { showPercentage: false, @@ -271,9 +300,22 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock rightBracket: ']', }, ) - : `[${pc.green('█'.repeat(Math.floor(barWidth * 0.15)))}${pc.gray('░'.repeat(barWidth - Math.floor(barWidth * 0.15)))}]`; - - const limitStatus = config.tokenLimit != null && config.tokenLimit > 0 + : usingTokenLimit && config.tokenLimit != null + ? createProgressBar( + projection.totalTokens, + config.tokenLimit, + barWidth, + { + showPercentage: false, + fillChar: projBarColor('█'), + emptyChar: pc.gray('░'), + leftBracket: '[', + rightBracket: ']', + }, + ) + : `[${pc.green('█'.repeat(Math.floor(barWidth * 0.15)))}${pc.gray('░'.repeat(barWidth - Math.floor(barWidth * 0.15)))}]`; + + const limitStatus = (usingCostLimit || usingTokenLimit) ? (projectedPercent > 100 ? pc.red('❌ WILL EXCEED LIMIT') : projectedPercent > 80 @@ -284,7 +326,26 @@ export function renderLiveDisplay(terminal: TerminalManager, block: SessionBlock // Projection section const projLabel = pc.bold('📈 PROJECTION'); const projLabelWidth = stringWidth(projLabel); - if (config.tokenLimit != null && config.tokenLimit > 0) { + if (usingCostLimit && config.costLimit != null) { + const projBarStr = `${projLabel}${''.padEnd(Math.max(0, labelWidth - projLabelWidth))} ${projectionBar} ${projectedPercent.toFixed(1).padStart(6)}% (${formatCurrency(projection.totalCost)}/${formatCurrency(config.costLimit)})`; + const projBarPadded = projBarStr + ' '.repeat(Math.max(0, boxWidth - 3 - stringWidth(projBarStr))); + terminal.write(`${marginStr}│ ${projBarPadded}│\n`); + + // Projection details (indented and aligned) + const col1 = `${pc.gray('Status:')} ${limitStatus}`; + const col2 = `${pc.gray('Cost:')} ${formatCurrency(projection.totalCost)}`; + const col3 = `${pc.gray('Tokens:')} ${formatNumber(projection.totalTokens)}`; + // Calculate visible lengths (without ANSI codes) + const col1Visible = stringWidth(col1); + const col2Visible = stringWidth(col2); + // Fixed column positions - match session alignment + const pad1 = ' '.repeat(Math.max(0, DETAIL_COLUMN_WIDTHS.col1 - col1Visible)); + const pad2 = ' '.repeat(Math.max(0, DETAIL_COLUMN_WIDTHS.col2 - col2Visible)); + const projDetails = ` ${col1}${pad1}${col2}${pad2}${col3}`; + const projDetailsPadded = projDetails + ' '.repeat(Math.max(0, boxWidth - 3 - stringWidth(projDetails))); + terminal.write(`${marginStr}│ ${projDetailsPadded}│\n`); + } + else if (usingTokenLimit && config.tokenLimit != null) { const projBarStr = `${projLabel}${''.padEnd(Math.max(0, labelWidth - projLabelWidth))} ${projectionBar} ${projectedPercent.toFixed(1).padStart(6)}% (${formatTokensShort(projection.totalTokens)}/${formatTokensShort(config.tokenLimit)})`; const projBarPadded = projBarStr + ' '.repeat(Math.max(0, boxWidth - 3 - stringWidth(projBarStr))); terminal.write(`${marginStr}│ ${projBarPadded}│\n`); @@ -362,19 +423,30 @@ export function renderCompactLiveDisplay( const sessionPercent = (elapsed / (elapsed + remaining)) * 100; terminal.write(`Session: ${sessionPercent.toFixed(1)}% (${Math.floor(elapsed / 60)}h ${Math.floor(elapsed % 60)}m)\n`); - // Token usage - if (config.tokenLimit != null && config.tokenLimit > 0) { + // Usage display - support both token and cost limits + const usingCostLimit = config.costLimit != null && config.costLimit > 0; + const usingTokenLimit = config.tokenLimit != null && config.tokenLimit > 0; + + if (usingCostLimit && config.costLimit != null) { + // Cost limit takes precedence + const costPercent = (block.costUSD / config.costLimit) * 100; + const status = costPercent > 100 ? pc.red('OVER') : costPercent > 80 ? pc.yellow('WARN') : pc.green('OK'); + terminal.write(`Cost: ${formatCurrency(block.costUSD)}/${formatCurrency(config.costLimit)} ${status}\n`); + terminal.write(`Tokens: ${formatNumber(totalTokens)}\n`); + } + else if (usingTokenLimit && config.tokenLimit != null) { + // Token limit only const tokenPercent = (totalTokens / config.tokenLimit) * 100; const status = tokenPercent > 100 ? pc.red('OVER') : tokenPercent > 80 ? pc.yellow('WARN') : pc.green('OK'); terminal.write(`Tokens: ${formatNumber(totalTokens)}/${formatNumber(config.tokenLimit)} ${status}\n`); + terminal.write(`Cost: ${formatCurrency(block.costUSD)}\n`); } else { + // No limits terminal.write(`Tokens: ${formatNumber(totalTokens)}\n`); + terminal.write(`Cost: ${formatCurrency(block.costUSD)}\n`); } - // Cost - terminal.write(`Cost: ${formatCurrency(block.costUSD)}\n`); - // Burn rate const burnRate = calculateBurnRate(block); if (burnRate != null) { @@ -421,4 +493,128 @@ if (import.meta.vitest != null) { .toThrow('This operation was aborted'); }); }); + + describe('renderCompactLiveDisplay', () => { + it('should display cost limit when provided', () => { + // Mock terminal with buffer to capture output + const buffer: string[] = []; + const mockTerminal = { + width: 50, + height: 20, + write: (str: string) => buffer.push(str), + startBuffering: () => {}, + flush: () => {}, + }; + + const mockBlock: SessionBlock = { + id: 'test-block-1', + startTime: new Date(), + endTime: new Date(), + costUSD: 2.5, + tokenCounts: { inputTokens: 5000, outputTokens: 3000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + entries: [], // Required by calculateBurnRate + isActive: false, + models: [], + }; + + const config: LiveMonitoringConfig = { + claudePath: '/test/path', + costLimit: 5.0, + tokenLimit: undefined, + refreshInterval: 1000, + sessionDurationHours: 5, + mode: 'auto', + order: 'desc', + }; + + // eslint-disable-next-line ts/no-unsafe-argument + renderCompactLiveDisplay(mockTerminal as any, mockBlock, config, 8000, 120, 60); + + const output = buffer.join(''); + expect(output).toContain('Cost: $2.50/$5.00'); + expect(output).toContain('OK'); // Status should be OK as we're at 50% + expect(output).toContain('Tokens: 8,000'); // Should show tokens without limit + }); + + it('should display token limit when provided', () => { + const buffer: string[] = []; + const mockTerminal = { + width: 50, + height: 20, + write: (str: string) => buffer.push(str), + startBuffering: () => {}, + flush: () => {}, + }; + + const mockBlock: SessionBlock = { + id: 'test-block-2', + startTime: new Date(), + endTime: new Date(), + costUSD: 2.5, + tokenCounts: { inputTokens: 5000, outputTokens: 3500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + entries: [], // Required by calculateBurnRate + isActive: false, + models: [], + }; + + const config: LiveMonitoringConfig = { + claudePath: '/test/path', + costLimit: undefined, + tokenLimit: 10000, + refreshInterval: 1000, + sessionDurationHours: 5, + mode: 'auto', + order: 'desc', + }; + + // eslint-disable-next-line ts/no-unsafe-argument + renderCompactLiveDisplay(mockTerminal as any, mockBlock, config, 8500, 120, 60); + + const output = buffer.join(''); + expect(output).toContain('Tokens: 8,500/10,000'); + expect(output).toContain('WARN'); // Status is WARN at 85% (> 80%) + expect(output).toContain('Cost: $2.50'); // Should show cost without limit + }); + + it('should prefer cost limit over token limit when both provided', () => { + const buffer: string[] = []; + const mockTerminal = { + width: 50, + height: 20, + write: (str: string) => buffer.push(str), + startBuffering: () => {}, + flush: () => {}, + }; + + const mockBlock: SessionBlock = { + id: 'test-block-3', + startTime: new Date(), + endTime: new Date(), + costUSD: 4.5, + tokenCounts: { inputTokens: 5000, outputTokens: 3000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + entries: [], // Required by calculateBurnRate + isActive: false, + models: [], + }; + + const config: LiveMonitoringConfig = { + claudePath: '/test/path', + costLimit: 5.0, + tokenLimit: 10000, + refreshInterval: 1000, + sessionDurationHours: 5, + mode: 'auto', + order: 'desc', + }; + + // eslint-disable-next-line ts/no-unsafe-argument + renderCompactLiveDisplay(mockTerminal as any, mockBlock, config, 8000, 120, 60); + + const output = buffer.join(''); + expect(output).toContain('Cost: $4.50/$5.00'); + expect(output).toContain('WARN'); // Status should be WARN as cost is at 90% (> 80%) + expect(output).toContain('Tokens: 8,000'); // Should show tokens without limit + expect(output).not.toContain('Tokens: 8,000/10,000'); // Should NOT show token limit + }); + }); } diff --git a/src/_session-blocks.ts b/src/_session-blocks.ts index 864d0047..ce1f76d4 100644 --- a/src/_session-blocks.ts +++ b/src/_session-blocks.ts @@ -59,12 +59,39 @@ export type SessionBlock = { models: string[]; }; +/** + * Represents per-model cost and token breakdown within a session block + */ +export type PerModelBreakdown = { + [modelName: string]: { + costUSD: number; + totalTokens: number; + entries: number; + }; +}; + +/** + * Represents historical values for ranking (2nd-max, 3rd-max, etc.) + */ +export type PerModelHistoricalValues = { + [modelName: string]: { + costUSD: number[]; + totalTokens: number[]; + entries: number[]; + }; +}; + /** * Represents usage burn rate calculations */ type BurnRate = { tokensPerMinute: number; costPerHour: number; + /** + * Activity density ratio (0-1) indicating how much of the time period + * the model was actually active. Used to adjust projections for sparse usage. + */ + activityDensity?: number; }; /** @@ -259,9 +286,54 @@ export function calculateBurnRate(block: SessionBlock): BurnRate | null { const tokensPerMinute = totalTokens / durationMinutes; const costPerHour = (block.costUSD / durationMinutes) * 60; + // Calculate activity density for sparse usage detection + let activityDensity: number | undefined; + + // Detect sparse usage by analyzing entry distribution + // If entries are very sparse compared to session duration, calculate density + const averageMinutesBetweenEntries = durationMinutes / Math.max(1, block.entries.length - 1); + const SPARSE_THRESHOLD_MINUTES = 5; // If entries are >5 minutes apart on average, consider sparse + + if (averageMinutesBetweenEntries > SPARSE_THRESHOLD_MINUTES && block.entries.length < 100) { + // Calculate the actual activity windows + const entryTimeSlots = block.entries.map(entry => entry.timestamp.getTime()); + entryTimeSlots.sort((a, b) => a - b); + + if (entryTimeSlots.length > 0) { + // Estimate active time by grouping entries into activity bursts + let totalActiveMinutes = 0; + let currentBurstStart = entryTimeSlots[0]!; + let lastEntryTime = entryTimeSlots[0]!; + const BURST_GAP_MINUTES = 10; // Entries within 10 minutes are considered part of same burst + + for (let i = 1; i < entryTimeSlots.length; i++) { + const currentTime = entryTimeSlots[i]!; + const gapMinutes = (currentTime - lastEntryTime) / (1000 * 60); + + if (gapMinutes > BURST_GAP_MINUTES) { + // End of current burst + totalActiveMinutes += (lastEntryTime - currentBurstStart) / (1000 * 60); + currentBurstStart = currentTime; + } + lastEntryTime = currentTime; + } + + // Add the final burst + totalActiveMinutes += (lastEntryTime - currentBurstStart) / (1000 * 60); + + // Ensure minimum active time (at least 1 minute per burst) + const numberOfBursts = Math.max(1, Math.ceil(totalActiveMinutes / BURST_GAP_MINUTES) || 1); + totalActiveMinutes = Math.max(totalActiveMinutes, numberOfBursts); + + // Calculate density as ratio of active time to total span + activityDensity = Math.min(1, totalActiveMinutes / durationMinutes); + } + } + return { tokensPerMinute, costPerHour, + activityDensity, }; } @@ -285,10 +357,19 @@ export function projectBlockUsage(block: SessionBlock): ProjectedUsage | null { const remainingMinutes = Math.max(0, remainingTime / (1000 * 60)); const currentTokens = block.tokenCounts.inputTokens + block.tokenCounts.outputTokens; - const projectedAdditionalTokens = burnRate.tokensPerMinute * remainingMinutes; + + // Adjust projection based on activity density for sparse usage + let effectiveRemainingMinutes = remainingMinutes; + if (burnRate.activityDensity != null && burnRate.activityDensity < 1) { + // For sparse usage, assume the model will only be active for a portion of the remaining time + // This prevents unrealistic projections for sporadically used models + effectiveRemainingMinutes = remainingMinutes * burnRate.activityDensity; + } + + const projectedAdditionalTokens = burnRate.tokensPerMinute * effectiveRemainingMinutes; const totalTokens = currentTokens + projectedAdditionalTokens; - const projectedAdditionalCost = (burnRate.costPerHour / 60) * remainingMinutes; + const projectedAdditionalCost = (burnRate.costPerHour / 60) * effectiveRemainingMinutes; const totalCost = block.costUSD + projectedAdditionalCost; return { @@ -298,6 +379,272 @@ export function projectBlockUsage(block: SessionBlock): ProjectedUsage | null { }; } +/** + * Calculates per-model cost and token breakdown for a session block + * @param block - Session block to analyze + * @returns Per-model breakdown with costs, tokens, and entry counts + */ +export function calculatePerModelCosts(block: SessionBlock): PerModelBreakdown { + const breakdown: PerModelBreakdown = {}; + + // Skip gap blocks + if (block.isGap ?? false) { + return breakdown; + } + + for (const entry of block.entries) { + const modelName = entry.model; + + if (breakdown[modelName] == null) { + breakdown[modelName] = { + costUSD: 0, + totalTokens: 0, + entries: 0, + }; + } + + breakdown[modelName].costUSD += entry.costUSD ?? 0; + breakdown[modelName].totalTokens += entry.usage.inputTokens + entry.usage.outputTokens; + breakdown[modelName].entries += 1; + } + + return breakdown; +} + +/** + * Finds the global maximum cost and tokens per model across all session blocks + * @param blocks - Array of session blocks to analyze + * @returns Global maximum values per model + */ +export function findGlobalModelMaxes(blocks: SessionBlock[]): PerModelBreakdown { + const globalMaxes: PerModelBreakdown = {}; + + for (const block of blocks) { + // Skip gap blocks and active blocks for historical max calculation + if ((block.isGap ?? false) || block.isActive) { + continue; + } + + const blockBreakdown = calculatePerModelCosts(block); + + for (const [modelName, modelData] of Object.entries(blockBreakdown)) { + if (globalMaxes[modelName] == null) { + globalMaxes[modelName] = { + costUSD: modelData.costUSD, + totalTokens: modelData.totalTokens, + entries: modelData.entries, + }; + } + else { + // Track maximum values for each metric + globalMaxes[modelName].costUSD = Math.max(globalMaxes[modelName].costUSD, modelData.costUSD); + globalMaxes[modelName].totalTokens = Math.max(globalMaxes[modelName].totalTokens, modelData.totalTokens); + globalMaxes[modelName].entries = Math.max(globalMaxes[modelName].entries, modelData.entries); + } + } + } + + return globalMaxes; +} + +/** + * Finds all historical values per model across completed session blocks for ranking + * Uses block-level costs and tokens for proper ranking, not per-model breakdown within blocks + * @param blocks - Array of session blocks to analyze + * @returns All historical values per model, sorted in descending order for ranking + */ +export function findGlobalModelHistoricalValues(blocks: SessionBlock[]): PerModelHistoricalValues { + const historicalValues: PerModelHistoricalValues = {}; + + for (const block of blocks) { + // Skip gap blocks and active blocks for historical ranking calculation + if ((block.isGap ?? false) || block.isActive) { + continue; + } + + // Only include blocks that have meaningful usage (non-zero cost or tokens) + const totalTokens = block.tokenCounts.inputTokens + block.tokenCounts.outputTokens; + const hasUsage = block.costUSD > 0 || totalTokens > 0; + if (!hasUsage) { + continue; + } + + // Use block-level costs and tokens, but organize by models used in the block + // This ensures that ranking is based on total block values, not per-model breakdowns + for (const modelName of block.models) { + if (historicalValues[modelName] == null) { + historicalValues[modelName] = { + costUSD: [], + totalTokens: [], + entries: [], + }; + } + + // Add the full block cost and tokens to each model that was used in the block + // This preserves block-level ranking while supporting model filtering + historicalValues[modelName].costUSD.push(block.costUSD); + historicalValues[modelName].totalTokens.push(totalTokens); + historicalValues[modelName].entries.push(block.entries.length); + } + } + + // Sort all arrays together by cost in descending order to maintain correspondence + for (const modelData of Object.values(historicalValues)) { + // Create array of indices and sort by cost (descending) + const indices = Array.from({ length: modelData.costUSD.length }, (_, i) => i); + indices.sort((a, b) => modelData.costUSD[b]! - modelData.costUSD[a]!); + + // Reorder all arrays according to the sorted indices + const sortedCosts = indices.map(i => modelData.costUSD[i]!); + const sortedTokens = indices.map(i => modelData.totalTokens[i]!); + const sortedEntries = indices.map(i => modelData.entries[i]!); + + modelData.costUSD = sortedCosts; + modelData.totalTokens = sortedTokens; + modelData.entries = sortedEntries; + } + + return historicalValues; +} + +/** + * Gets the nth highest cost from historical values for the specified models + * @param historicalValues - Historical values per model + * @param models - Array of model names to filter by (undefined for no filter) + * @param rank - Rank to retrieve (1 = highest, 2 = second highest, etc.) + * @returns Nth highest cost value or undefined if insufficient data + */ +export function getNthHighestCost(historicalValues: PerModelHistoricalValues, models: string[] | undefined, rank: number): number | undefined { + if (rank < 1) { + return undefined; + } + + // Collect all cost values from matching models + const allCosts: number[] = []; + + for (const [modelName, modelData] of Object.entries(historicalValues)) { + const modelMatches = models == null || models.length === 0 || models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + const lowerModelName = modelName.toLowerCase(); + return lowerModelName.includes(lowerFilterModel) || lowerFilterModel.includes(lowerModelName); + }); + + if (modelMatches) { + allCosts.push(...modelData.costUSD); + } + } + + // Sort all costs in descending order + allCosts.sort((a, b) => b - a); + + // For unfiltered queries, deduplicate costs to get unique block costs + // For filtered queries, keep duplicates since they represent different occurrences + if (models == null || models.length === 0) { + const uniqueCosts = [...new Set(allCosts)]; + return uniqueCosts[rank - 1]; + } + + return allCosts[rank - 1]; +} + +/** + * Gets the nth highest token count from historical values for the specified models + * @param historicalValues - Historical values per model + * @param models - Array of model names to filter by (undefined for no filter) + * @param rank - Rank to retrieve (1 = highest, 2 = second highest, etc.) + * @returns Nth highest token count or undefined if insufficient data + */ +export function getNthHighestTokens(historicalValues: PerModelHistoricalValues, models: string[] | undefined, rank: number): number | undefined { + if (rank < 1) { + return undefined; + } + + // Collect all token counts from matching models + const allTokens: number[] = []; + + for (const [modelName, modelData] of Object.entries(historicalValues)) { + const modelMatches = models == null || models.length === 0 || models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + const lowerModelName = modelName.toLowerCase(); + return lowerModelName.includes(lowerFilterModel) || lowerFilterModel.includes(lowerModelName); + }); + + if (modelMatches) { + allTokens.push(...modelData.totalTokens); + } + } + + // Sort all token counts in descending order + allTokens.sort((a, b) => b - a); + + // For unfiltered queries, deduplicate tokens to get unique block values + // For filtered queries, keep duplicates since they represent different occurrences + if (models == null || models.length === 0) { + const uniqueTokens = [...new Set(allTokens)]; + return uniqueTokens[rank - 1]; + } + + return allTokens[rank - 1]; +} + +/** + * Gets the appropriate cost limit for the specified models + * @param globalMaxes - Global per-model maximum values + * @param models - Array of model names to filter by (undefined for no filter) + * @returns Maximum cost limit for the specified models + */ +export function getModelCostLimit(globalMaxes: PerModelBreakdown, models?: string[]): number { + if (models == null || models.length === 0) { + // No model filter - return the highest cost across all models + return Math.max(...Object.values(globalMaxes).map(data => data.costUSD), 0); + } + + // Model filter applied - find the highest cost among matching models + let maxCost = 0; + for (const [modelName, modelData] of Object.entries(globalMaxes)) { + const modelMatches = models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + const lowerModelName = modelName.toLowerCase(); + return lowerModelName.includes(lowerFilterModel) || lowerFilterModel.includes(lowerModelName); + }); + + if (modelMatches) { + maxCost = Math.max(maxCost, modelData.costUSD); + } + } + + return maxCost; +} + +/** + * Gets the appropriate token limit for the specified models + * @param globalMaxes - Global per-model maximum values + * @param models - Array of model names to filter by (undefined for no filter) + * @returns Maximum token limit for the specified models + */ +export function getModelTokenLimit(globalMaxes: PerModelBreakdown, models?: string[]): number { + if (models == null || models.length === 0) { + // No model filter - return the highest token count across all models + return Math.max(...Object.values(globalMaxes).map(data => data.totalTokens), 0); + } + + // Model filter applied - find the highest token count among matching models + let maxTokens = 0; + for (const [modelName, modelData] of Object.entries(globalMaxes)) { + const modelMatches = models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + const lowerModelName = modelName.toLowerCase(); + return lowerModelName.includes(lowerFilterModel) || lowerFilterModel.includes(lowerModelName); + }); + + if (modelMatches) { + maxTokens = Math.max(maxTokens, modelData.totalTokens); + } + } + + return maxTokens; +} + /** * Filters session blocks to include only recent ones and active blocks * @param blocks - Array of session blocks to filter @@ -950,4 +1297,325 @@ if (import.meta.vitest != null) { expect(blocksDefault[0]!.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); }); }); + + describe('findGlobalModelHistoricalValues', () => { + it('returns empty object for empty blocks', () => { + const result = findGlobalModelHistoricalValues([]); + expect(result).toEqual({}); + }); + + it('collects block-level historical values from completed blocks', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const blocks: SessionBlock[] = [ + { + id: baseTime.toISOString(), + startTime: baseTime, + endTime: new Date(baseTime.getTime() + 5 * 60 * 60 * 1000), + entries: [ + createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 10.50), + createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000), 2000, 1000, 'claude-opus-4-20250514', 25.00), + ], + tokenCounts: { inputTokens: 3000, outputTokens: 1500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 35.50, // Block-level cost (total for this 5-hour block) + isActive: false, + models: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'], + }, + { + id: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 15 * 60 * 60 * 1000), + entries: [ + createMockEntry(new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), 1500, 750, 'claude-sonnet-4-20250514', 8.00), + ], + tokenCounts: { inputTokens: 1500, outputTokens: 750, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 8.00, // Block-level cost + isActive: false, + models: ['claude-sonnet-4-20250514'], + }, + ]; + + const result = findGlobalModelHistoricalValues(blocks); + + // Both models in first block get the full block cost and tokens + expect(result).toEqual({ + 'claude-sonnet-4-20250514': { + costUSD: [35.50, 8.00], // Full block costs, sorted descending + totalTokens: [4500, 2250], // Full block tokens, sorted descending (4500 > 2250) + entries: [2, 1], // Number of entries in each block + }, + 'claude-opus-4-20250514': { + costUSD: [35.50], // Full block cost (shared with sonnet) + totalTokens: [4500], // Full block tokens (shared with sonnet) + entries: [2], // Total entries in the block + }, + }); + }); + + it('skips gap blocks and active blocks', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const blocks: SessionBlock[] = [ + { + id: baseTime.toISOString(), + startTime: baseTime, + endTime: new Date(baseTime.getTime() + 5 * 60 * 60 * 1000), + entries: [createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 10.50)], + tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 10.50, + isActive: false, + models: ['claude-sonnet-4-20250514'], + }, + { + id: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 15 * 60 * 60 * 1000), + entries: [], + tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 0, + isActive: false, + isGap: true, + models: [], + }, + { + id: new Date(baseTime.getTime() + 20 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 20 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 25 * 60 * 60 * 1000), + entries: [createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000), 2000, 1000, 'claude-sonnet-4-20250514', 15.00)], + tokenCounts: { inputTokens: 2000, outputTokens: 1000, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 15.00, + isActive: true, + models: ['claude-sonnet-4-20250514'], + }, + ]; + + const result = findGlobalModelHistoricalValues(blocks); + + expect(result).toEqual({ + 'claude-sonnet-4-20250514': { + costUSD: [10.50], // only the completed block + totalTokens: [1500], // only the completed block (1000 + 500) + entries: [1], + }, + }); + }); + + it('filters out zero values', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const blocks: SessionBlock[] = [ + { + id: baseTime.toISOString(), + startTime: baseTime, + endTime: new Date(baseTime.getTime() + 5 * 60 * 60 * 1000), + entries: [createMockEntry(baseTime, 0, 0, 'claude-sonnet-4-20250514', 0)], + tokenCounts: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 0, + isActive: false, + models: ['claude-sonnet-4-20250514'], + }, + { + id: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 15 * 60 * 60 * 1000), + entries: [createMockEntry(new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), 1000, 500, 'claude-sonnet-4-20250514', 10.50)], + tokenCounts: { inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 10.50, + isActive: false, + models: ['claude-sonnet-4-20250514'], + }, + ]; + + const result = findGlobalModelHistoricalValues(blocks); + + expect(result).toEqual({ + 'claude-sonnet-4-20250514': { + costUSD: [10.50], // zero cost/token blocks filtered out + totalTokens: [1500], // zero cost/token blocks filtered out (1000 + 500) + entries: [1], // zero cost/token blocks filtered out + }, + }); + }); + + it('uses block-level costs for ranking, not per-model breakdown', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const blocks: SessionBlock[] = [ + { + id: baseTime.toISOString(), + startTime: baseTime, + endTime: new Date(baseTime.getTime() + 5 * 60 * 60 * 1000), + entries: [ + createMockEntry(baseTime, 1000, 500, 'claude-sonnet-4-20250514', 50.00), + createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000), 2000, 1000, 'claude-opus-4-20250514', 86.34), + ], + tokenCounts: { inputTokens: 3000, outputTokens: 1500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 136.34, // Total block cost - this should be used for ranking, not individual per-model costs + isActive: false, + models: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'], + }, + { + id: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 15 * 60 * 60 * 1000), + entries: [ + createMockEntry(new Date(baseTime.getTime() + 10 * 60 * 60 * 1000), 5000, 2500, 'claude-sonnet-4-20250514', 196.97), + ], + tokenCounts: { inputTokens: 5000, outputTokens: 2500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 196.97, // Highest block cost + isActive: false, + models: ['claude-sonnet-4-20250514'], + }, + { + id: new Date(baseTime.getTime() + 20 * 60 * 60 * 1000).toISOString(), + startTime: new Date(baseTime.getTime() + 20 * 60 * 60 * 1000), + endTime: new Date(baseTime.getTime() + 25 * 60 * 60 * 1000), + entries: [ + createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000), 3000, 1500, 'claude-opus-4-20250514', 122.14), + ], + tokenCounts: { inputTokens: 3000, outputTokens: 1500, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + costUSD: 122.14, // Third highest block cost + isActive: false, + models: ['claude-opus-4-20250514'], + }, + ]; + + const result = findGlobalModelHistoricalValues(blocks); + + // Each model should have the full block costs where it was used, sorted descending + expect(result).toEqual({ + 'claude-sonnet-4-20250514': { + costUSD: [196.97, 136.34], // Full block costs (highest first) + totalTokens: [7500, 4500], // Full block tokens (7500 > 4500) + entries: [1, 2], // Number of entries in each block (1 entry in 196.97 block, 2 entries in 136.34 block) + }, + 'claude-opus-4-20250514': { + costUSD: [136.34, 122.14], // Full block costs (higher first) + totalTokens: [4500, 4500], // Full block tokens (same tokens, so same order as costs) + entries: [2, 1], // Number of entries in each block (2 entries in 136.34 block, 1 entry in 122.14 block) + }, + }); + + // Verify ranking behavior with deduplication for unfiltered queries + const firstMax = getNthHighestCost(result, undefined, 1); + const secondMax = getNthHighestCost(result, undefined, 2); + const thirdMax = getNthHighestCost(result, undefined, 3); + + expect(firstMax).toBe(196.97); + expect(secondMax).toBe(136.34); // This was the bug - it was returning per-model cost instead + expect(thirdMax).toBe(122.14); // Unique costs: [196.97, 136.34, 122.14] + + // Verify that model filtering preserves duplicates (different behavior) + const secondMaxSonnet = getNthHighestCost(result, ['sonnet'], 2); + const thirdMaxSonnet = getNthHighestCost(result, ['sonnet'], 3); + expect(secondMaxSonnet).toBe(136.34); // With filtering, sonnet appears in 196.97 and 136.34 blocks + expect(thirdMaxSonnet).toBeUndefined(); // Only 2 blocks contain sonnet + }); + }); + + describe('getNthHighestCost', () => { + const historicalValues: PerModelHistoricalValues = { + 'claude-sonnet-4-20250514': { + costUSD: [10.50, 8.00, 6.50], // sorted descending + totalTokens: [1500, 1200, 1000], + entries: [1, 1, 1], + }, + 'claude-opus-4-20250514': { + costUSD: [25.00, 20.00], + totalTokens: [3000, 2500], + entries: [1, 1], + }, + }; + + it('returns 1st highest (max) across all models', () => { + const result = getNthHighestCost(historicalValues, undefined, 1); + expect(result).toBe(25.00); // highest across all models + }); + + it('returns 2nd highest across all models', () => { + const result = getNthHighestCost(historicalValues, undefined, 2); + expect(result).toBe(20.00); // 2nd highest across all models + }); + + it('returns 3rd highest across all models', () => { + const result = getNthHighestCost(historicalValues, undefined, 3); + expect(result).toBe(10.50); // 3rd highest across all models + }); + + it('returns undefined for insufficient data', () => { + const result = getNthHighestCost(historicalValues, undefined, 10); + expect(result).toBeUndefined(); + }); + + it('filters by model correctly', () => { + const result = getNthHighestCost(historicalValues, ['sonnet'], 1); + expect(result).toBe(10.50); // highest for sonnet model + }); + + it('returns 2nd highest for filtered model', () => { + const result = getNthHighestCost(historicalValues, ['sonnet'], 2); + expect(result).toBe(8.00); // 2nd highest for sonnet model + }); + + it('returns undefined for invalid rank', () => { + const result = getNthHighestCost(historicalValues, undefined, 0); + expect(result).toBeUndefined(); + }); + + it('handles empty historical values', () => { + const result = getNthHighestCost({}, undefined, 1); + expect(result).toBeUndefined(); + }); + }); + + describe('getNthHighestTokens', () => { + const historicalValues: PerModelHistoricalValues = { + 'claude-sonnet-4-20250514': { + costUSD: [10.50, 8.00, 6.50], + totalTokens: [1500, 1200, 1000], // sorted descending + entries: [1, 1, 1], + }, + 'claude-opus-4-20250514': { + costUSD: [25.00, 20.00], + totalTokens: [3000, 2500], // sorted descending + entries: [1, 1], + }, + }; + + it('returns 1st highest (max) tokens across all models', () => { + const result = getNthHighestTokens(historicalValues, undefined, 1); + expect(result).toBe(3000); // highest across all models + }); + + it('returns 2nd highest tokens across all models', () => { + const result = getNthHighestTokens(historicalValues, undefined, 2); + expect(result).toBe(2500); // 2nd highest across all models + }); + + it('returns 3rd highest tokens across all models', () => { + const result = getNthHighestTokens(historicalValues, undefined, 3); + expect(result).toBe(1500); // 3rd highest across all models + }); + + it('returns undefined for insufficient data', () => { + const result = getNthHighestTokens(historicalValues, undefined, 10); + expect(result).toBeUndefined(); + }); + + it('filters by model correctly', () => { + const result = getNthHighestTokens(historicalValues, ['opus'], 1); + expect(result).toBe(3000); // highest for opus model + }); + + it('returns 2nd highest for filtered model', () => { + const result = getNthHighestTokens(historicalValues, ['opus'], 2); + expect(result).toBe(2500); // 2nd highest for opus model + }); + + it('returns undefined for invalid rank', () => { + const result = getNthHighestTokens(historicalValues, undefined, 0); + expect(result).toBeUndefined(); + }); + + it('handles empty historical values', () => { + const result = getNthHighestTokens({}, undefined, 1); + expect(result).toBeUndefined(); + }); + }); } diff --git a/src/commands/_blocks.live.ts b/src/commands/_blocks.live.ts index ea2eb1d6..c5f4b6c7 100644 --- a/src/commands/_blocks.live.ts +++ b/src/commands/_blocks.live.ts @@ -50,6 +50,7 @@ export async function startLiveMonitoring(config: LiveMonitoringConfig): Promise sessionDurationHours: config.sessionDurationHours, mode: config.mode, order: config.order, + models: config.models, }); try { diff --git a/src/commands/blocks.ts b/src/commands/blocks.ts index 28b9195b..c0f57ef0 100644 --- a/src/commands/blocks.ts +++ b/src/commands/blocks.ts @@ -1,4 +1,4 @@ -import type { SessionBlock } from '../_session-blocks.ts'; +import type { PerModelHistoricalValues, SessionBlock } from '../_session-blocks.ts'; import process from 'node:process'; import { define } from 'gunshi'; import pc from 'picocolors'; @@ -7,6 +7,9 @@ import { calculateBurnRate, DEFAULT_SESSION_DURATION_HOURS, filterRecentBlocks, + findGlobalModelHistoricalValues, + getNthHighestCost, + getNthHighestTokens, projectBlockUsage, } from '../_session-blocks.ts'; @@ -87,24 +90,81 @@ function formatModels(models: string[]): string { } /** - * Parses token limit argument, supporting 'max' keyword + * Parses token limit argument, supporting 'max' and ranking patterns like '2nd-max', '3rd-max' * @param value - Token limit string value - * @param maxFromAll - Maximum token count found in all blocks + * @param historicalValues - Historical values per model for ranking + * @param models - Array of model names to filter by (undefined for no filter) * @returns Parsed token limit or undefined if invalid */ -function parseTokenLimit(value: string | undefined, maxFromAll: number): number | undefined { +function parseTokenLimit(value: string | undefined, historicalValues: PerModelHistoricalValues, models?: string[]): number | undefined { if (value == null || value === '') { return undefined; } + // Handle 'max' keyword (1st highest) if (value === 'max') { - return maxFromAll > 0 ? maxFromAll : undefined; + return getNthHighestTokens(historicalValues, models, 1); } + // Handle ranking patterns like '2nd-max', '3rd-max', '10th-max' + const rankMatch = value.match(/^(\d+)(?:st|nd|rd|th)-max$/); + if (rankMatch != null && rankMatch[1] != null) { + const rank = Number.parseInt(rankMatch[1], 10); + if (rank >= 1) { + return getNthHighestTokens(historicalValues, models, rank); + } + } + + // Handle numeric values const limit = Number.parseInt(value, 10); return Number.isNaN(limit) ? undefined : limit; } +/** + * Parses cost limit argument, supporting 'max' and ranking patterns like '2nd-max', '3rd-max' + * @param value - Cost limit string value + * @param historicalValues - Historical values per model for ranking + * @param models - Array of model names to filter by (undefined for no filter) + * @returns Parsed cost limit or undefined if invalid + */ +function parseCostLimit(value: string | undefined, historicalValues: PerModelHistoricalValues, models?: string[]): number | undefined { + if (value == null || value === '') { + return undefined; + } + + // Handle 'max' keyword (1st highest) + if (value === 'max') { + return getNthHighestCost(historicalValues, models, 1); + } + + // Handle ranking patterns like '2nd-max', '3rd-max', '10th-max' + const rankMatch = value.match(/^(\d+)(?:st|nd|rd|th)-max$/); + if (rankMatch != null && rankMatch[1] != null) { + const rank = Number.parseInt(rankMatch[1], 10); + if (rank >= 1) { + return getNthHighestCost(historicalValues, models, rank); + } + } + + // Handle numeric values + const limit = Number.parseFloat(value); + return Number.isNaN(limit) ? undefined : limit; +} + +/** + * Parses model filter argument + * @param value - Model filter string value (comma-separated or single model) + * @returns Array of model names or undefined if not specified + */ +function parseModelFilter(value: string | undefined): string[] | undefined { + if (value == null || value === '') { + return undefined; + } + + // Split by comma and trim whitespace + return value.split(',').map(model => model.trim()).filter(model => model !== ''); +} + export const blocksCommand = define({ name: 'blocks', description: 'Show usage report grouped by session billing blocks', @@ -125,7 +185,12 @@ export const blocksCommand = define({ tokenLimit: { type: 'string', short: 't', - description: 'Token limit for quota warnings (e.g., 500000 or "max")', + description: 'Token limit for quota warnings (e.g., 500000, "max", "2nd-max", "3rd-max")', + }, + costLimit: { + type: 'string', + short: 'c', + description: 'Cost limit for quota warnings (e.g., 5.50, "max", "2nd-max", "3rd-max")', }, sessionLength: { type: 'number', @@ -143,6 +208,11 @@ export const blocksCommand = define({ description: `Refresh interval in seconds for live mode (default: ${DEFAULT_REFRESH_INTERVAL_SECONDS})`, default: DEFAULT_REFRESH_INTERVAL_SECONDS, }, + model: { + type: 'string', + short: 'm', + description: 'Filter by specific model(s) - comma-separated or single model name', + }, }, toKebab: true, async run(ctx) { @@ -156,6 +226,44 @@ export const blocksCommand = define({ process.exit(1); } + // Load unfiltered data first for max limit calculations + const allBlocks = await loadSessionBlockData({ + since: ctx.values.since, + until: ctx.values.until, + mode: ctx.values.mode, + order: ctx.values.order, + offline: ctx.values.offline, + sessionDurationHours: ctx.values.sessionLength, + // No model filtering for limit calculations + }); + + // Calculate per-model historical values from ALL blocks (unfiltered data) for ranking + const globalModelHistoricalValues = findGlobalModelHistoricalValues(allBlocks); + const modelFilter = parseModelFilter(ctx.values.model); + + // Calculate token limit using ranking analysis + let tokenLimitFromRanking: number | undefined; + if (ctx.values.tokenLimit != null) { + tokenLimitFromRanking = parseTokenLimit(ctx.values.tokenLimit, globalModelHistoricalValues, modelFilter); + if (ctx.values.json !== true && tokenLimitFromRanking != null && tokenLimitFromRanking > 0) { + const modelText = modelFilter != null ? ` for ${modelFilter.join(', ')} model(s)` : ' across all models'; + const rankText = ctx.values.tokenLimit === 'max' ? 'max' : ctx.values.tokenLimit; + logger.info(`Using ${rankText} tokens from previous sessions${modelText}: ${formatNumber(tokenLimitFromRanking)}`); + } + } + + // Calculate cost limit using ranking analysis + let costLimitFromRanking: number | undefined; + if (ctx.values.costLimit != null) { + costLimitFromRanking = parseCostLimit(ctx.values.costLimit, globalModelHistoricalValues, modelFilter); + if (ctx.values.json !== true && costLimitFromRanking != null && costLimitFromRanking > 0) { + const modelText = modelFilter != null ? ` for ${modelFilter.join(', ')} model(s)` : ' across all models'; + const rankText = ctx.values.costLimit === 'max' ? 'max' : ctx.values.costLimit; + logger.info(`Using ${rankText} cost from previous sessions${modelText}: $${costLimitFromRanking.toFixed(2)}`); + } + } + + // Now load filtered data for actual display let blocks = await loadSessionBlockData({ since: ctx.values.since, until: ctx.values.until, @@ -163,6 +271,7 @@ export const blocksCommand = define({ order: ctx.values.order, offline: ctx.values.offline, sessionDurationHours: ctx.values.sessionLength, + models: modelFilter, }); if (blocks.length === 0) { @@ -175,22 +284,6 @@ export const blocksCommand = define({ process.exit(0); } - // Calculate max tokens from ALL blocks before applying filters - let maxTokensFromAll = 0; - if (ctx.values.tokenLimit === 'max') { - for (const block of blocks) { - if (!(block.isGap ?? false) && !block.isActive) { - const blockTokens = block.tokenCounts.inputTokens + block.tokenCounts.outputTokens; - if (blockTokens > maxTokensFromAll) { - maxTokensFromAll = blockTokens; - } - } - } - if (!ctx.values.json && maxTokensFromAll > 0) { - logger.info(`Using max tokens from previous sessions: ${formatNumber(maxTokensFromAll)}`); - } - } - // Apply filters if (ctx.values.recent) { blocks = filterRecentBlocks(blocks, DEFAULT_RECENT_DAYS); @@ -216,12 +309,31 @@ export const blocksCommand = define({ logger.info('Live mode automatically shows only active blocks.'); } + // Validate mutual exclusivity of token and cost limits + if (ctx.values.tokenLimit != null && ctx.values.costLimit != null) { + logger.error('Cannot specify both --token-limit and --cost-limit at the same time'); + process.exit(1); + } + // Default to 'max' if no token limit specified in live mode let tokenLimitValue = ctx.values.tokenLimit; - if (tokenLimitValue == null || tokenLimitValue === '') { + const costLimitValue = ctx.values.costLimit; + + if (tokenLimitValue == null && costLimitValue == null) { tokenLimitValue = 'max'; - if (maxTokensFromAll > 0) { - logger.info(`No token limit specified, using max from previous sessions: ${formatNumber(maxTokensFromAll)}`); + if (tokenLimitFromRanking != null && tokenLimitFromRanking > 0) { + logger.info(`No limit specified, using token limit max from previous sessions: ${formatNumber(tokenLimitFromRanking)}`); + } + } + else if (tokenLimitValue == null || tokenLimitValue === '') { + if (costLimitValue != null) { + // Cost limit is specified, don't default token limit + } + else { + tokenLimitValue = 'max'; + if (tokenLimitFromRanking != null && tokenLimitFromRanking > 0) { + logger.info(`No token limit specified, using max from previous sessions: ${formatNumber(tokenLimitFromRanking)}`); + } } } @@ -240,11 +352,13 @@ export const blocksCommand = define({ await startLiveMonitoring({ claudePath: paths[0]!, - tokenLimit: parseTokenLimit(tokenLimitValue, maxTokensFromAll), + tokenLimit: parseTokenLimit(tokenLimitValue, globalModelHistoricalValues, modelFilter), + costLimit: parseCostLimit(costLimitValue, globalModelHistoricalValues, modelFilter), refreshInterval: refreshInterval * 1000, // Convert to milliseconds sessionDurationHours: ctx.values.sessionLength, mode: ctx.values.mode, order: ctx.values.order, + models: modelFilter, }); return; // Exit early, don't show table } @@ -274,7 +388,7 @@ export const blocksCommand = define({ projection, tokenLimitStatus: projection != null && ctx.values.tokenLimit != null ? (() => { - const limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); + const limit = parseTokenLimit(ctx.values.tokenLimit, globalModelHistoricalValues, modelFilter); return limit != null ? { limit, @@ -336,7 +450,7 @@ export const blocksCommand = define({ if (ctx.values.tokenLimit != null) { // Parse token limit - const limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); + const limit = parseTokenLimit(ctx.values.tokenLimit, globalModelHistoricalValues, modelFilter); if (limit != null && limit > 0) { const currentTokens = block.tokenCounts.inputTokens + block.tokenCounts.outputTokens; const remainingTokens = Math.max(0, limit - currentTokens); @@ -361,7 +475,7 @@ export const blocksCommand = define({ logger.box('Claude Code Token Usage Report - Session Blocks'); // Calculate token limit if "max" is specified - const actualTokenLimit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); + const actualTokenLimit = parseTokenLimit(ctx.values.tokenLimit, globalModelHistoricalValues, modelFilter); const tableHeaders = ['Block Start', 'Duration/Status', 'Models', 'Tokens']; const tableAligns: ('left' | 'right' | 'center')[] = ['left', 'left', 'left', 'right']; diff --git a/src/data-loader.ts b/src/data-loader.ts index d4cf40af..d94137eb 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -610,6 +610,7 @@ export type LoadOptions = { order?: SortOrder; // Sort order for dates offline?: boolean; // Use offline mode for pricing sessionDurationHours?: number; // Session block duration in hours + models?: string[]; // Filter by specific models } & DateFilter; /** @@ -996,6 +997,78 @@ export async function loadMonthlyUsageData( return sortByDate(monthlyArray, item => `${item.month}-01`, options?.order); } +/** + * Filters session blocks' entries by specified models while preserving block structure + * @param blocks - Array of session blocks to filter + * @param models - Array of model names to filter by + * @returns Array of session blocks with filtered entries and recalculated aggregates + */ +function filterBlocksByModels(blocks: SessionBlock[], models: string[]): SessionBlock[] { + return blocks.map((block) => { + // Skip gap blocks + if (block.isGap ?? false) { + return block; + } + + // Filter entries by models with partial matching + const filteredEntries = block.entries.filter((entry) => { + const entryModel = entry.model.toLowerCase(); + return models.some((filterModel) => { + const lowerFilterModel = filterModel.toLowerCase(); + return entryModel.includes(lowerFilterModel) || lowerFilterModel.includes(entryModel); + }); + }); + + // If no entries match, return empty block with preserved structure + if (filteredEntries.length === 0) { + return { + ...block, + entries: [], + tokenCounts: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + costUSD: 0, + models: [], + }; + } + + // Recalculate aggregated data for filtered entries + const tokenCounts = { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }; + + let totalCost = 0; + const uniqueModels = new Set(); + + for (const entry of filteredEntries) { + tokenCounts.inputTokens += entry.usage.inputTokens; + tokenCounts.outputTokens += entry.usage.outputTokens; + tokenCounts.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens; + tokenCounts.cacheReadInputTokens += entry.usage.cacheReadInputTokens; + + if (entry.costUSD != null) { + totalCost += entry.costUSD; + } + + uniqueModels.add(entry.model); + } + + return { + ...block, + entries: filteredEntries, + tokenCounts, + costUSD: totalCost, + models: Array.from(uniqueModels).sort(), + }; + }); +} + /** * Loads usage data and organizes it into session blocks (typically 5-hour billing periods) * Processes all usage data and groups it into time-based blocks for billing analysis @@ -1088,12 +1161,17 @@ export async function loadSessionBlockData( } } - // Identify session blocks + // Identify session blocks from ALL entries first (preserve session structure) const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); + // Apply model filtering to blocks while preserving structure + const filteredBlocks = options?.models != null && options.models.length > 0 + ? filterBlocksByModels(blocks, options.models) + : blocks; + // Filter by date range if specified const filtered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '') - ? blocks.filter((block) => { + ? filteredBlocks.filter((block) => { const blockDateStr = formatDate(block.startTime.toISOString()).replace(/-/g, ''); if (options.since != null && options.since !== '' && blockDateStr < options.since) { return false; @@ -1103,7 +1181,7 @@ export async function loadSessionBlockData( } return true; }) - : blocks; + : filteredBlocks; // Sort by start time based on order option return sortByDate(filtered, block => block.startTime, options?.order);