Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/ccusage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) - <synthetic> 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.

Comment on lines +124 to +163
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language identifier to fenced code block.

The documentation is comprehensive and well-structured. However, the fenced code block at line 148 is missing a language identifier.

Apply this diff to fix the linting issue:

-```
+```text
 Block Start                     Duration/Status  Models             Tokens   Prompts     %     Cost
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

148-148: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In apps/ccusage/README.md around lines 124 to 163, the fenced code block
starting at line ~148 is missing a language identifier which causes a lint
warning; update the opening fence to include a language (use "text") so the
block starts with ```text and keep the existing closing ``` intact to fix the
linting issue.

## 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.)
Expand Down
6 changes: 6 additions & 0 deletions apps/ccusage/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/ccusage/src/_live-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down
53 changes: 40 additions & 13 deletions apps/ccusage/src/_session-blocks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
};

/**
Expand Down Expand Up @@ -89,6 +91,7 @@ type ProjectedUsage = {
*/
export function identifySessionBlocks(
entries: LoadedUsageEntry[],
userMessages: UserMessage[] = [],
sessionDurationHours = DEFAULT_SESSION_DURATION_HOURS,
): SessionBlock[] {
if (entries.length === 0) {
Expand All @@ -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[] = [];
Expand All @@ -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
Expand All @@ -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);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -200,6 +210,7 @@ function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, se
costUSD,
models: uniq(models),
usageLimitResetTime,
userPromptCount,
};
}

Expand Down Expand Up @@ -235,6 +246,7 @@ function createGapBlock(lastActivityTime: Date, nextActivityTime: Date, sessionD
},
costUSD: 0,
models: [],
userPromptCount: 0,
};
}

Expand Down Expand Up @@ -505,6 +517,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0,
userPromptCount: 0,
models: [],
};

Expand All @@ -527,6 +540,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0,
userPromptCount: 0,
models: [],
};

Expand All @@ -552,6 +566,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.02,
userPromptCount: 0,
models: ['claude-sonnet-4-20250514'],
};

Expand All @@ -578,6 +593,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.03,
userPromptCount: 0,
models: ['claude-sonnet-4-20250514'],
};

Expand Down Expand Up @@ -616,6 +632,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 8000,
},
costUSD: 0.03,
userPromptCount: 0,
models: ['claude-sonnet-4-20250514'],
};

Expand All @@ -642,6 +659,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
};

Expand All @@ -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();
});

Expand All @@ -685,6 +704,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
};

Expand Down Expand Up @@ -714,6 +734,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.03,
userPromptCount: 0,
models: ['claude-sonnet-4-20250514'],
};

Expand Down Expand Up @@ -745,6 +766,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
},
{
Expand All @@ -760,6 +782,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.02,
userPromptCount: 0,
models: [],
},
];
Expand Down Expand Up @@ -787,6 +810,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
},
];
Expand Down Expand Up @@ -815,6 +839,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
},
{
Expand All @@ -830,6 +855,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.02,
userPromptCount: 0,
models: [],
},
];
Expand Down Expand Up @@ -857,6 +883,7 @@ if (import.meta.vitest != null) {
cacheReadInputTokens: 0,
},
costUSD: 0.01,
userPromptCount: 0,
models: [],
},
];
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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
Expand All @@ -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);
});
Expand All @@ -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);
Expand Down
Loading