diff --git a/apps/ccusage/README.md b/apps/ccusage/README.md index 527f0ebf..2360c1f2 100644 --- a/apps/ccusage/README.md +++ b/apps/ccusage/README.md @@ -101,6 +101,9 @@ npx ccusage statusline # Compact status line for hooks (Beta) # Live monitoring npx ccusage blocks --live # Real-time usage dashboard +# Prompt counting (useful for GLM and other models without token limits) +npx ccusage blocks --prompts # Show number of prompts per 5-hour block + # Filters and options npx ccusage daily --since 20250525 --until 20250530 npx ccusage daily --json # JSON output @@ -118,12 +121,53 @@ npx ccusage --compact # Force compact table mode npx ccusage monthly --compact # Compact monthly report ``` +## Prompt Counting for GLM and Other Models + +The `--prompts` flag in the `blocks` command is particularly useful for users of GLM (General Language Model) and other AI models that don't have strict token limits or when you want to track usage patterns rather than costs. + +### Why Use Prompt Counting? + +- **GLM Users**: GLM models often have different pricing models or usage limits based on the number of prompts rather than tokens +- **Usage Pattern Analysis**: Understand your interaction frequency with AI models +- **Productivity Tracking**: Monitor how many prompts you send during different time periods +- **Budget Planning**: Some services charge per prompt rather than per token + +### How It Works + +```bash +# Show prompts per 5-hour block +npx ccusage blocks --prompts + +# Combined with other flags +npx ccusage blocks --prompts --recent # Last 3 days +npx ccusage blocks --prompts --json # JSON output +``` + +### Example Output + +``` +Block Start Duration/Status Models Tokens Prompts % Cost +2025-09-05, 2:00:00 PM (13m) - sonnet-4 92,531 6 0.1% $0.07 +2025-09-10, 7:00:00 PM (3h 10m) - 22,299,… 249 13.2% $9.57 +2025-09-11, 8:00:00 AM (1h 13m) - sonnet-4 2,567,4… 36 1.5% $1.35 +``` + +### GLM-Specific Use Cases + +- **Prompt Rate Tracking**: Monitor how many prompts you send per 5-hour window +- **Usage Patterns**: Identify your most productive hours +- **Service Limit Monitoring**: Track usage against prompt-based service limits +- **Cost Analysis**: For services that charge per prompt, calculate costs per 5-hour block + +The prompt count works by counting individual JSONL entries in each session block, where each entry represents one complete prompt/response interaction with the AI model. + ## Features - 📊 **Daily Report**: View token usage and costs aggregated by date - 📅 **Monthly Report**: View token usage and costs aggregated by month - 💬 **Session Report**: View usage grouped by conversation sessions - ⏰ **5-Hour Blocks Report**: Track usage within Claude's billing windows with active block monitoring +- 🔢 **Prompt Counting**: Show the number of prompts sent in each 5-hour block with `blocks --prompts` - perfect for GLM and other models without strict token limits - 📈 **Live Monitoring**: Real-time dashboard showing active session progress, token burn rate, and cost projections with `blocks --live` - 🚀 **Statusline Integration**: Compact usage display for Claude Code status bar hooks (Beta) - 🤖 **Model Tracking**: See which Claude models you're using (Opus, Sonnet, etc.) diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 34392dde..84a5b618 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -653,6 +653,12 @@ "description": "Session block duration in hours (default: 5)", "markdownDescription": "Session block duration in hours (default: 5)", "default": 5 + }, + "prompts": { + "type": "boolean", + "description": "Show number of prompts in each block", + "markdownDescription": "Show number of prompts in each block", + "default": false } }, "additionalProperties": false diff --git a/apps/ccusage/src/_live-monitor.ts b/apps/ccusage/src/_live-monitor.ts index 2c380881..1ba3427c 100644 --- a/apps/ccusage/src/_live-monitor.ts +++ b/apps/ccusage/src/_live-monitor.ts @@ -250,6 +250,7 @@ export async function getActiveBlock( // Generate blocks and find active one const blocks = identifySessionBlocks( state.allEntries, + [], // No user messages in live monitoring (yet) config.sessionDurationHours, ); diff --git a/apps/ccusage/src/_session-blocks.ts b/apps/ccusage/src/_session-blocks.ts index 7694902f..340dbf86 100644 --- a/apps/ccusage/src/_session-blocks.ts +++ b/apps/ccusage/src/_session-blocks.ts @@ -1,3 +1,4 @@ +import type { UserMessage } from './data-loader.ts'; import { uniq } from 'es-toolkit'; import { DEFAULT_RECENT_DAYS } from './_consts.ts'; import { getTotalTokens } from './_token-utils.ts'; @@ -60,6 +61,7 @@ export type SessionBlock = { costUSD: number; models: string[]; usageLimitResetTime?: Date; // Claude API usage limit reset time + userPromptCount: number; // Number of user prompts sent in this block }; /** @@ -89,6 +91,7 @@ type ProjectedUsage = { */ export function identifySessionBlocks( entries: LoadedUsageEntry[], + userMessages: UserMessage[] = [], sessionDurationHours = DEFAULT_SESSION_DURATION_HOURS, ): SessionBlock[] { if (entries.length === 0) { @@ -98,6 +101,7 @@ export function identifySessionBlocks( const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; const blocks: SessionBlock[] = []; const sortedEntries = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); + const sortedUserMessages = [...userMessages].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); let currentBlockStart: Date | null = null; let currentBlockEntries: LoadedUsageEntry[] = []; @@ -122,7 +126,7 @@ export function identifySessionBlocks( if (timeSinceBlockStart > sessionDurationMs || timeSinceLastEntry > sessionDurationMs) { // Close current block - const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); + const block = createBlock(currentBlockStart, currentBlockEntries, sortedUserMessages, now, sessionDurationMs); blocks.push(block); // Add gap block if there's a significant gap @@ -146,7 +150,7 @@ export function identifySessionBlocks( // Close the last block if (currentBlockStart != null && currentBlockEntries.length > 0) { - const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs); + const block = createBlock(currentBlockStart, currentBlockEntries, sortedUserMessages, now, sessionDurationMs); blocks.push(block); } @@ -161,7 +165,7 @@ export function identifySessionBlocks( * @param sessionDurationMs - Session duration in milliseconds * @returns Session block with aggregated data */ -function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, sessionDurationMs: number): SessionBlock { +function createBlock(startTime: Date, entries: LoadedUsageEntry[], userMessages: UserMessage[], now: Date, sessionDurationMs: number): SessionBlock { const endTime = new Date(startTime.getTime() + sessionDurationMs); const lastEntry = entries[entries.length - 1]; const actualEndTime = lastEntry != null ? lastEntry.timestamp : startTime; @@ -189,6 +193,12 @@ function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, se models.push(entry.model); } + // Count user messages in this block's time range + const userPromptCount = userMessages.filter((userMsg) => { + const msgTime = new Date(userMsg.timestamp); + return msgTime >= startTime && msgTime < endTime; + }).length; + return { id: startTime.toISOString(), startTime, @@ -200,6 +210,7 @@ function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, se costUSD, models: uniq(models), usageLimitResetTime, + userPromptCount, }; } @@ -235,6 +246,7 @@ function createGapBlock(lastActivityTime: Date, nextActivityTime: Date, sessionD }, costUSD: 0, models: [], + userPromptCount: 0, }; } @@ -505,6 +517,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0, + userPromptCount: 0, models: [], }; @@ -527,6 +540,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0, + userPromptCount: 0, models: [], }; @@ -552,6 +566,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.02, + userPromptCount: 0, models: ['claude-sonnet-4-20250514'], }; @@ -578,6 +593,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.03, + userPromptCount: 0, models: ['claude-sonnet-4-20250514'], }; @@ -616,6 +632,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 8000, }, costUSD: 0.03, + userPromptCount: 0, models: ['claude-sonnet-4-20250514'], }; @@ -642,6 +659,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }; @@ -664,10 +682,11 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0, + userPromptCount: 0, models: [], }; - const result = projectBlockUsage(block); + const result = calculateBurnRate(block); expect(result).toBeNull(); }); @@ -685,6 +704,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }; @@ -714,6 +734,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.03, + userPromptCount: 0, models: ['claude-sonnet-4-20250514'], }; @@ -745,6 +766,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }, { @@ -760,6 +782,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.02, + userPromptCount: 0, models: [], }, ]; @@ -787,6 +810,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }, ]; @@ -815,6 +839,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }, { @@ -830,6 +855,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.02, + userPromptCount: 0, models: [], }, ]; @@ -857,6 +883,7 @@ if (import.meta.vitest != null) { cacheReadInputTokens: 0, }, costUSD: 0.01, + userPromptCount: 0, models: [], }, ]; @@ -875,7 +902,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later ]; - const blocks = identifySessionBlocks(entries, 3); + const blocks = identifySessionBlocks(entries, [], 3); expect(blocks).toHaveLength(1); expect(blocks[0]?.startTime).toEqual(baseTime); expect(blocks[0]?.entries).toHaveLength(3); @@ -889,7 +916,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000)), // 3 hours later (beyond 2h limit) ]; - const blocks = identifySessionBlocks(entries, 2); + const blocks = identifySessionBlocks(entries, [], 2); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(1); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)); @@ -905,7 +932,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (beyond 1h) ]; - const blocks = identifySessionBlocks(entries, 1); + const blocks = identifySessionBlocks(entries, [], 1); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[1]?.isGap).toBe(true); @@ -920,7 +947,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later (4 hours from last entry, beyond 2.5h) ]; - const blocks = identifySessionBlocks(entries, 2.5); + const blocks = identifySessionBlocks(entries, [], 2.5); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2.5 * 60 * 60 * 1000)); @@ -936,7 +963,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 80 * 60 * 1000)), // 80 minutes later (60 minutes from last entry, beyond 0.5h) ]; - const blocks = identifySessionBlocks(entries, 0.5); + const blocks = identifySessionBlocks(entries, [], 0.5); expect(blocks).toHaveLength(3); // first block, gap block, second block expect(blocks[0]?.entries).toHaveLength(2); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 0.5 * 60 * 60 * 1000)); @@ -952,7 +979,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000)), // 20 hours later (within 24h) ]; - const blocks = identifySessionBlocks(entries, 24); + const blocks = identifySessionBlocks(entries, [], 24); expect(blocks).toHaveLength(1); // single block expect(blocks[0]?.entries).toHaveLength(3); expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 24 * 60 * 60 * 1000)); @@ -966,7 +993,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)), // 5 hours later (4h from last entry, beyond 3h) ]; - const blocks = identifySessionBlocks(entries, 3); + const blocks = identifySessionBlocks(entries, [], 3); expect(blocks).toHaveLength(3); // first block, gap block, second block // Gap block should start 3 hours after last activity in first block @@ -983,7 +1010,7 @@ if (import.meta.vitest != null) { createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // exactly 2 hours later (equal to session duration) ]; - const blocks = identifySessionBlocks(entries, 2); + const blocks = identifySessionBlocks(entries, [], 2); expect(blocks).toHaveLength(1); // single block (entries are exactly at session boundary) expect(blocks[0]?.entries).toHaveLength(2); }); @@ -995,7 +1022,7 @@ if (import.meta.vitest != null) { ]; const blocksDefault = identifySessionBlocks(entries); - const blocksExplicit = identifySessionBlocks(entries, 5); + const blocksExplicit = identifySessionBlocks(entries, [], 5); expect(blocksDefault).toHaveLength(1); expect(blocksExplicit).toHaveLength(1); diff --git a/apps/ccusage/src/commands/blocks.ts b/apps/ccusage/src/commands/blocks.ts index 57ef1520..011691a2 100644 --- a/apps/ccusage/src/commands/blocks.ts +++ b/apps/ccusage/src/commands/blocks.ts @@ -144,6 +144,12 @@ export const blocksCommand = define({ description: `Refresh interval in seconds for live mode (default: ${DEFAULT_REFRESH_INTERVAL_SECONDS})`, default: DEFAULT_REFRESH_INTERVAL_SECONDS, }, + prompts: { + type: 'boolean', + short: 'p', + description: 'Show number of prompts in each block', + default: false, + }, }, toKebab: true, async run(ctx) { @@ -385,6 +391,12 @@ export const blocksCommand = define({ const tableHeaders = ['Block Start', 'Duration/Status', 'Models', 'Tokens']; const tableAligns: ('left' | 'right' | 'center')[] = ['left', 'left', 'left', 'right']; + // Add prompts column if requested + if (ctx.values.prompts) { + tableHeaders.push('Prompts'); + tableAligns.push('right'); + } + // Add % column if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { tableHeaders.push('%'); @@ -417,6 +429,9 @@ export const blocksCommand = define({ pc.gray('-'), pc.gray('-'), ]; + if (ctx.values.prompts) { + gapRow.push(pc.gray('-')); + } if (actualTokenLimit != null && actualTokenLimit > 0) { gapRow.push(pc.gray('-')); } @@ -435,6 +450,11 @@ export const blocksCommand = define({ formatNumber(totalTokens), ]; + // Add prompts count if requested + if (ctx.values.prompts) { + row.push(formatNumber(block.userPromptCount)); + } + // Add percentage if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { const percentage = (totalTokens / actualTokenLimit) * 100; @@ -466,9 +486,15 @@ export const blocksCommand = define({ pc.blue('REMAINING'), '', remainingText, - remainingPercentText, - '', // No cost for remaining - it's about token limit, not cost ]; + + // Add prompts column if requested + if (ctx.values.prompts) { + remainingRow.push(''); + } + + remainingRow.push(remainingPercentText); + remainingRow.push(''); // No cost for remaining - it's about token limit, not cost table.push(remainingRow); } @@ -487,6 +513,11 @@ export const blocksCommand = define({ projectedText, ]; + // Add prompts column if requested + if (ctx.values.prompts) { + projectedRow.push(''); + } + // Add percentage if token limit is set if (actualTokenLimit != null && actualTokenLimit > 0) { const percentage = (projection.totalTokens / actualTokenLimit) * 100; diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index efa28ee6..24b47507 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -207,6 +207,24 @@ export const transcriptMessageSchema = v.object({ */ export type UsageData = v.InferOutput; +/** + * Type definition for user messages from JSONL files + */ +export type UserMessage = v.InferOutput; + +/** + * Valibot schema for user messages (no usage data, just timestamps) + */ +export const userMessageSchema = v.object({ + timestamp: isoTimestampSchema, + type: v.literal('user'), + message: v.object({ + role: v.literal('user'), + content: v.union([v.string(), v.array(v.any())]), + }), + sessionId: v.optional(sessionIdSchema), +}); + /** * Valibot schema for model-specific usage breakdown data */ @@ -521,12 +539,13 @@ export function createUniqueHash(data: UsageData): string | null { const messageId = data.message.id; const requestId = data.requestId; - if (messageId == null || requestId == null) { + if (messageId == null) { return null; } - // Create a hash using simple concatenation - return `${messageId}:${requestId}`; + // Use both messageId and requestId if available, otherwise just messageId + // This handles cases where requestId is missing from JSONL entries + return requestId != null ? `${messageId}:${requestId}` : messageId; } /** @@ -1351,6 +1370,8 @@ export async function loadSessionBlockData( // Collect all valid data entries first const allEntries: LoadedUsageEntry[] = []; + // Also collect user messages for prompt counting + const allUserMessages: UserMessage[] = []; for (const file of sortedFiles) { const content = await readFile(file, 'utf-8'); @@ -1362,42 +1383,54 @@ export async function loadSessionBlockData( for (const line of lines) { try { const parsed = JSON.parse(line) as unknown; - const result = v.safeParse(usageDataSchema, parsed); - if (!result.success) { + + // Try to parse as usage data (assistant messages with tokens) + const usageResult = v.safeParse(usageDataSchema, parsed); + if (usageResult.success) { + const data = usageResult.output; + + // Check for duplicate message + request ID combination + const uniqueHash = createUniqueHash(data); + if (isDuplicateEntry(uniqueHash, processedHashes)) { + // Skip duplicate message + continue; + } + + // Mark this combination as processed + markAsProcessed(uniqueHash, processedHashes); + + const cost = fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : data.costUSD ?? 0; + + // Get Claude Code usage limit expiration date + const usageLimitResetTime = getUsageLimitResetTime(data); + + allEntries.push({ + timestamp: new Date(data.timestamp), + usage: { + inputTokens: data.message.usage.input_tokens, + outputTokens: data.message.usage.output_tokens, + cacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0, + }, + costUSD: cost, + model: data.message.model ?? 'unknown', + version: data.version, + usageLimitResetTime: usageLimitResetTime ?? undefined, + }); continue; } - const data = result.output; - // Check for duplicate message + request ID combination - const uniqueHash = createUniqueHash(data); - if (isDuplicateEntry(uniqueHash, processedHashes)) { - // Skip duplicate message + // Try to parse as user message (for prompt counting) + const userResult = v.safeParse(userMessageSchema, parsed); + if (userResult.success) { + const userMessage = userResult.output; + allUserMessages.push(userMessage); continue; } - // Mark this combination as processed - markAsProcessed(uniqueHash, processedHashes); - - const cost = fetcher != null - ? await calculateCostForEntry(data, mode, fetcher) - : data.costUSD ?? 0; - - // Get Claude Code usage limit expiration date - const usageLimitResetTime = getUsageLimitResetTime(data); - - allEntries.push({ - timestamp: new Date(data.timestamp), - usage: { - inputTokens: data.message.usage.input_tokens, - outputTokens: data.message.usage.output_tokens, - cacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0, - cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0, - }, - costUSD: cost, - model: data.message.model ?? 'unknown', - version: data.version, - usageLimitResetTime: usageLimitResetTime ?? undefined, - }); + // Skip lines that don't match either schema } catch (error) { // Skip invalid JSON lines but log for debugging purposes @@ -1407,7 +1440,7 @@ export async function loadSessionBlockData( } // Identify session blocks - const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); + const blocks = identifySessionBlocks(allEntries, allUserMessages, options?.sessionDurationHours); // Filter by date range if specified const dateFiltered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '')