Skip to content
Merged
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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ npx ccusage-fleet daily --hosts localhost,rtzr --group-by none
# Append a report-bucket token distribution graph
npx ccusage-fleet daily --hosts localhost,rtzr --graph

# Scale the graph by estimated cost
# Scale the graph by estimated cost, or by output tokens
npx ccusage-fleet daily --hosts localhost,rtzr --graph --graph-metric cost
npx ccusage-fleet daily --hosts localhost,rtzr --graph --graph-metric output

# One consistent date range and timezone on every device
npx ccusage-fleet daily \
Expand Down Expand Up @@ -101,10 +102,28 @@ The former `--by-host` and `--no-by-host` options remain as aliases for `--group

## Graph

Add `--graph` to append a proportional distribution chart after the table. Bars use total tokens by default; choose `--graph-metric cost` to scale them by estimated cost. Buckets follow the report command: days for `daily`, weeks for `weekly`, and months for `monthly`.
Add `--graph` to append a proportional distribution chart after the table. Buckets follow the report command: days for `daily`, weeks for `weekly`, and months for `monthly`.

| `--graph-metric` | Bars scale by |
| --- | --- |
| `tokens` (default) | Total tokens, which cache reads usually dominate |
| `output` | Output tokens, the closest proxy for work performed |
| `cost` | Estimated cost |

Hour buckets will require a future normalized event export from ccusage; ccusage-fleet does not copy raw transcripts to approximate them.

## Reading the numbers

Total tokens counts every token an agent sent or received, and cache reads are typically over 90% of that: each turn replays the whole cached context, billed at a fraction of the input rate. A fleet-wide total spanning months across several machines therefore reaches billions of tokens without anything being double counted. Use `--graph-metric output` or the `Output` column to compare actual work, and `--since` to narrow the `Total` row to a period you care about.

ccusage-fleet prints a `Note:` line under the table whenever the report needs that context:

- how much of the total is cache reads
- models with no entry in the pricing table, which count as `$0` and make the reported cost a lower bound
- reasoning tokens that ccusage folds into total tokens without a column of their own

Costs are list-price estimates from ccusage's pricing table, not billed amounts, and they are meaningless on subscription plans. Pricing is fetched online by default so that recently released models are priced; `--offline` uses the snapshot bundled with ccusage instead, which reports `$0` for any model newer than that snapshot.

## Failure handling

By default, reachable hosts are reported even when another host fails. Add `--strict` to return a non-zero exit status if any host fails. `--timeout` is applied per host.
Expand Down
2 changes: 1 addition & 1 deletion ccusage-fleet.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"timezone": { "type": "string" },
"groupBy": { "enum": ["agent", "device", "none"] },
"graph": { "type": "boolean" },
"graphMetric": { "enum": ["tokens", "cost"] },
"graphMetric": { "enum": ["tokens", "output", "cost"] },
"byHost": { "type": "boolean" },
"offline": { "type": "boolean" },
"noCost": { "type": "boolean" },
Expand Down
56 changes: 28 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@
"node": ">=20"
},
"dependencies": {
"ccusage": "20.0.17"
"ccusage": "20.0.19"
}
}
4 changes: 2 additions & 2 deletions src/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ REPORT
--group-by <mode> Group detail rows by agent, device, or none (default: agent)
--by-host / --no-by-host Compatibility aliases for --group-by device/none
--graph / --no-graph Show a report-bucket distribution graph
--graph-metric <metric> Scale graph bars by tokens or cost (default: tokens)
--graph-metric <metric> Scale graph bars by tokens, output, or cost (default: tokens)
--no-cost Hide costs
--offline / --online Use bundled or online ccusage pricing (default: offline)
--offline / --online Use bundled or online ccusage pricing (default: online)

EXECUTION
--timeout <ms> Per-host timeout (default: 120000)
Expand Down
7 changes: 6 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { discoverConfigPath, loadConfig, resolveSettings } from './config.js';
import { mapConcurrent, runHost } from './runner.js';
import { renderFleetTable } from './table.js';
import { renderFleetGraph } from './graph.js';
import { fleetNotes, renderNotes } from './insights.js';

const here = dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8'));
Expand Down Expand Up @@ -60,10 +61,14 @@ export async function runCli(argv, dependencies = {}) {
}

const fleet = aggregateFleet(results, settings);
const notes = fleetNotes(fleet, settings);
if (settings.json) {
process.stdout.write(`${JSON.stringify(fleet, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ...fleet, notes }, null, 2)}\n`);
} else {
const sections = [renderFleetTable(fleet, settings)];
if (notes.length > 0) {
sections.push(renderNotes(notes));
}
if (settings.graph) {
sections.push(renderFleetGraph(fleet, settings));
}
Expand Down
6 changes: 3 additions & 3 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ export function resolveSettings(parsedOptions, config, defaults) {
throw new Error(`group-by must be agent, device, or none (received '${groupBy}')`);
}
const graphMetric = parsedOptions.graphMetric ?? config.graphMetric ?? 'tokens';
if (!['tokens', 'cost'].includes(graphMetric)) {
throw new Error(`graph-metric must be tokens or cost (received '${graphMetric}')`);
if (!['tokens', 'output', 'cost'].includes(graphMetric)) {
throw new Error(`graph-metric must be tokens, output, or cost (received '${graphMetric}')`);
}
const noCost = parsedOptions.noCost ?? config.noCost ?? false;
if (graphMetric === 'cost' && noCost) {
Expand All @@ -163,7 +163,7 @@ export function resolveSettings(parsedOptions, config, defaults) {
hosts,
json: parsedOptions.json ?? false,
noCost,
offline: parsedOptions.offline ?? config.offline ?? true,
offline: parsedOptions.offline ?? config.offline ?? false,
since: parsedOptions.since,
sshConnectTimeoutSeconds: integer(
parsedOptions.sshConnectTimeoutSeconds ?? config.sshConnectTimeoutSeconds,
Expand Down
12 changes: 12 additions & 0 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function number(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}

export function compact(value, prefix = '') {
const numeric = number(value);
const absolute = Math.abs(numeric);
if (absolute >= 1_000_000_000) return `${prefix}${(numeric / 1_000_000_000).toFixed(1)}B`;
if (absolute >= 1_000_000) return `${prefix}${(numeric / 1_000_000).toFixed(1)}M`;
if (absolute >= 1_000) return `${prefix}${(numeric / 1_000).toFixed(1)}K`;
return `${prefix}${Math.round(numeric)}`;
}
32 changes: 13 additions & 19 deletions src/graph.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
function number(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
import { compact, number } from './format.js';

function compact(value, prefix = '') {
const numeric = number(value);
const absolute = Math.abs(numeric);
if (absolute >= 1_000_000_000) return `${prefix}${(numeric / 1_000_000_000).toFixed(1)}B`;
if (absolute >= 1_000_000) return `${prefix}${(numeric / 1_000_000).toFixed(1)}M`;
if (absolute >= 1_000) return `${prefix}${(numeric / 1_000).toFixed(1)}K`;
return `${prefix}${Math.round(numeric)}`;
}
const METRICS = new Map([
['cost', { key: 'totalCost', prefix: '$', title: 'Cost' }],
['output', { key: 'outputTokens', prefix: '', title: 'Output tokens' }],
['tokens', { key: 'totalTokens', prefix: '', title: 'Total tokens' }],
]);

function bar(value, peak, width) {
if (value <= 0 || peak <= 0) return ' '.repeat(width);
Expand Down Expand Up @@ -38,25 +33,24 @@ function border(left, middle, right, widths, fill = '─') {
export function renderFleetGraph(fleet, settings, terminalWidth = process.stdout.columns ?? 120) {
const rows = fleet[fleet.command];
const bucketName = fleet.command === 'weekly' ? 'week' : fleet.command === 'monthly' ? 'month' : 'day';
const metricKey = settings.graphMetric === 'cost' ? 'totalCost' : 'totalTokens';
const values = rows.map((row) => number(row[metricKey]));
const metric = METRICS.get(settings.graphMetric) ?? METRICS.get('tokens');
const values = rows.map((row) => number(row[metric.key]));
const total = values.reduce((sum, value) => sum + value, 0);
const peak = Math.max(0, ...values);
const metricTitle = settings.graphMetric === 'cost' ? 'Cost' : 'Total tokens';
const metricValue = (value) => settings.graphMetric === 'cost' ? compact(value, '$') : compact(value);
const title = `${metricTitle} over time · ${bucketName} buckets · total ${metricValue(total)} · peak ${metricValue(peak)}`;
const metricValue = (value) => compact(value, metric.prefix);
const title = `${metric.title} over time · ${bucketName} buckets · total ${metricValue(total)} · peak ${metricValue(peak)}`;

const widths = [10, 10, 10, Math.max(16, Math.min(60, terminalWidth - 46))];
const output = [title, border('╭', '┬', '╮', widths)];
const headers = ['BUCKET', 'TOKENS', 'COST', 'DISTRIBUTION'];
const headers = ['BUCKET', settings.graphMetric === 'output' ? 'OUTPUT' : 'TOKENS', 'COST', 'DISTRIBUTION'];
output.push(`│ ${headers.map((header, index) => pad(header, widths[index])).join(' ┆ ')} │`);
output.push(border('╞', '╪', '╡', widths, '═'));
for (const row of rows) {
const cells = [
pad(row.period, widths[0]),
pad(compact(row.totalTokens), widths[1], true),
pad(compact(settings.graphMetric === 'output' ? row.outputTokens : row.totalTokens), widths[1], true),
pad(settings.noCost ? '—' : compact(row.totalCost, '$'), widths[2], true),
bar(number(row[metricKey]), peak, widths[3]),
bar(number(row[metric.key]), peak, widths[3]),
];
output.push(`│ ${cells.join(' ┆ ')} │`);
}
Expand Down
72 changes: 72 additions & 0 deletions src/insights.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { compact, number } from './format.js';

const COMPONENT_FIELDS = ['inputTokens', 'outputTokens', 'cacheCreationTokens', 'cacheReadTokens'];
const LISTED_MODELS = 3;
const CACHE_READ_NOTE_THRESHOLD = 0.5;

function componentSum(usage) {
return COMPONENT_FIELDS.reduce((sum, field) => sum + number(usage[field]), 0);
}

// ccusage reports one cost per model, so a model priced at exactly zero while it
// still moved tokens means the pricing table had no entry for it.
export function unpricedModels(totals) {
return (totals?.modelBreakdowns ?? [])
.map((item) => ({ modelName: item.modelName ?? 'unknown', tokens: componentSum(item), cost: number(item.cost) }))
.filter((item) => item.tokens > 0 && item.cost === 0)
.sort((a, b) => b.tokens - a.tokens);
}

// ccusage folds agent-specific reasoning tokens into totalTokens without giving
// them a column of their own, so the visible columns can add up to slightly less.
export function reasoningDrift(totals) {
return number(totals?.totalTokens) - componentSum(totals);
}

export function fleetNotes(fleet, settings = {}) {
const totals = fleet?.totals ?? {};
const totalTokens = number(totals.totalTokens);
const notes = [];
if (totalTokens <= 0) {
return notes;
}

const cacheRead = number(totals.cacheReadTokens);
if (cacheRead / totalTokens > CACHE_READ_NOTE_THRESHOLD) {
notes.push(
`Cache reads are ${((cacheRead / totalTokens) * 100).toFixed(1)}% of Total Tokens `
+ `(${compact(cacheRead)} of ${compact(totalTokens)}); output is ${compact(totals.outputTokens)}. `
+ 'Total Tokens tracks context replay, not work performed.',
);
}

if (!settings.noCost) {
const unpriced = unpricedModels(totals);
if (unpriced.length > 0) {
const unpricedTokens = unpriced.reduce((sum, item) => sum + item.tokens, 0);
const listed = unpriced.slice(0, LISTED_MODELS).map((item) => `${item.modelName} ${compact(item.tokens)}`);
const remaining = unpriced.length - listed.length;
const suffix = remaining > 0 ? `, +${remaining} more` : '';
notes.push(
`${unpriced.length} model(s) have no pricing data and count as $0 `
+ `(${((unpricedTokens / totalTokens) * 100).toFixed(1)}% of tokens): ${listed.join(', ')}${suffix}. `
+ 'Reported cost is a lower bound.'
+ (settings.offline ? ' Re-run with --online to fetch current pricing.' : ''),
);
}
}

const drift = reasoningDrift(totals);
if (drift !== 0) {
notes.push(
`${compact(drift)} tokens (${((Math.abs(drift) / totalTokens) * 100).toFixed(4)}%) are reasoning tokens `
+ 'counted in Total Tokens but not in the Input/Output/Cache columns.',
);
}

return notes;
}

export function renderNotes(notes) {
return notes.map((note) => `Note: ${note}`).join('\n');
}
15 changes: 15 additions & 0 deletions test/args-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,25 @@ test('configures token and cost graphs', () => {

const cost = resolveSettings(parseArgs(['--graph', '--graph-metric', 'cost']).options, {}, defaults);
assert.equal(cost.graphMetric, 'cost');

const outputTokens = resolveSettings(parseArgs(['--graph', '--graph-metric', 'output']).options, {}, defaults);
assert.equal(outputTokens.graphMetric, 'output');

assert.throws(
() => resolveSettings(parseArgs(['--graph-metric', 'cost', '--no-cost']).options, {}, defaults),
/cannot be combined with --no-cost/,
);
assert.throws(
() => resolveSettings(parseArgs(['--graph-metric', 'reads']).options, {}, defaults),
/graph-metric must be tokens, output, or cost/,
);
});

test('fetches pricing online by default so new models are not counted as $0', () => {
assert.equal(resolveSettings(parseArgs([]).options, {}, defaults).offline, false);
assert.equal(resolveSettings(parseArgs(['--offline']).options, {}, defaults).offline, true);
assert.equal(resolveSettings(parseArgs([]).options, { offline: true }, defaults).offline, true);
assert.equal(resolveSettings(parseArgs(['--online']).options, { offline: true }, defaults).offline, false);
});

test('rejects SSH option and shell injection', () => {
Expand Down
Loading
Loading