Skip to content
60 changes: 48 additions & 12 deletions apps/ccusage/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,14 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "boolean",
"description": "Visual output mode with compact table, sparklines, and heatmap",
"markdownDescription": "Visual output mode with compact table, sparklines, and heatmap",
"default": false
}
},
Expand Down Expand Up @@ -191,10 +197,16 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "string",
"enum": ["compact", "bar", "spark", "heatmap"],
"description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)",
"markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)"
},
"instances": {
"type": "boolean",
"description": "Show usage breakdown by project/instance",
Expand Down Expand Up @@ -299,9 +311,15 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "string",
"enum": ["compact", "bar", "spark", "heatmap"],
"description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)",
"markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -391,10 +409,16 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "string",
"enum": ["compact", "bar", "spark", "heatmap"],
"description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)",
"markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)"
},
"startOfWeek": {
"type": "string",
"enum": [
Expand Down Expand Up @@ -491,10 +515,16 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "string",
"enum": ["compact", "bar", "spark", "heatmap"],
"description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)",
"markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)"
},
"id": {
"type": "string",
"description": "Load usage data for a specific session ID",
Expand Down Expand Up @@ -588,10 +618,16 @@
},
"compact": {
"type": "boolean",
"description": "Force compact mode for narrow displays (better for screenshots)",
"markdownDescription": "Force compact mode for narrow displays (better for screenshots)",
"description": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays",
"default": false
},
"visual": {
"type": "string",
"enum": ["compact", "bar", "spark", "heatmap"],
"description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)",
"markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)"
},
"active": {
"type": "boolean",
"description": "Show only active block with projections",
Expand Down
119 changes: 116 additions & 3 deletions apps/ccusage/src/_pricing-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { LiteLLMModelPricing } from '@ccusage/internal/pricing';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing';
import { Result } from '@praha/byethrow';
import { prefetchClaudePricing } from './_macro.ts' with { type: 'macro' };
Expand All @@ -13,27 +17,136 @@ const CLAUDE_PROVIDER_PREFIXES = [

const PREFETCHED_CLAUDE_PRICING = prefetchClaudePricing();

/** Cache TTL in milliseconds (24 hours) */
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;

/** Get the cache directory path */
function getCacheDir(): string {
return path.join(os.homedir(), '.cache', 'ccusage');
}

/** Get the pricing cache file path */
function getCacheFilePath(): string {
return path.join(getCacheDir(), 'pricing.json');
}

/** Check if the cache file is still valid (less than 24 hours old) */
async function isCacheValid(): Promise<boolean> {
try {
const stats = await fs.stat(getCacheFilePath());
const age = Date.now() - stats.mtimeMs;
return age < CACHE_TTL_MS;
} catch {
return false;
}
}

/** Load pricing from disk cache */
async function loadDiskCache(): Promise<Record<string, LiteLLMModelPricing> | null> {
try {
const data = await fs.readFile(getCacheFilePath(), 'utf-8');
return JSON.parse(data) as Record<string, LiteLLMModelPricing>;
} catch {
return null;
}
}

/** Save pricing to disk cache */
async function saveDiskCache(pricing: Map<string, LiteLLMModelPricing>): Promise<void> {
try {
const cacheDir = getCacheDir();
await fs.mkdir(cacheDir, { recursive: true });
const obj = Object.fromEntries(pricing);
await fs.writeFile(getCacheFilePath(), JSON.stringify(obj));
} catch (error) {
logger.debug('Failed to save pricing cache:', error);
}
}

export type PricingFetcherOptions = {
/** Use bundled offline pricing (ignores cache and network) */
offline?: boolean;
/** Force refresh from network (ignores cache) */
refreshPricing?: boolean;
};

export class PricingFetcher extends LiteLLMPricingFetcher {
constructor(offline = false) {
private readonly refreshPricing: boolean;
private diskCacheLoaded = false;

constructor(options: PricingFetcherOptions | boolean = {}) {
// support legacy boolean signature
const opts = typeof options === 'boolean' ? { offline: options } : options;

super({
offline,
offline: opts.offline ?? false,
offlineLoader: async () => PREFETCHED_CLAUDE_PRICING,
logger,
providerPrefixes: CLAUDE_PROVIDER_PREFIXES,
});

this.refreshPricing = opts.refreshPricing ?? false;
}

override async fetchModelPricing(): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {
// if refresh requested, skip cache
if (this.refreshPricing) {
logger.debug('Refresh pricing flag set, fetching fresh data');
return this.fetchAndCache();
}

// try disk cache first (unless offline mode which uses bundled data)
if (!this.diskCacheLoaded) {
this.diskCacheLoaded = true;
const cacheValid = await isCacheValid();
if (cacheValid) {
const cached = await loadDiskCache();
if (cached != null) {
const pricing = new Map(Object.entries(cached));
logger.debug(`Using cached pricing for ${pricing.size} models`);
// set internal cache so subsequent calls use it
this.setCachedPricing(pricing);
return Result.succeed(pricing);
}
}
}

// fall back to parent implementation (network fetch or offline)
return this.fetchAndCache();
}

private async fetchAndCache(): Result.ResultAsync<Map<string, LiteLLMModelPricing>, Error> {
const result = await super.fetchModelPricing();
if (Result.isSuccess(result)) {
// save to disk cache for next run
await saveDiskCache(result.value);
}
return result;
}

private setCachedPricing(pricing: Map<string, LiteLLMModelPricing>): void {
// access parent's private cache via type assertion
(this as unknown as { cachedPricing: Map<string, LiteLLMModelPricing> | null }).cachedPricing =
pricing;
}
}

if (import.meta.vitest != null) {
describe('PricingFetcher', () => {
it('loads offline pricing when offline flag is true', async () => {
using fetcher = new PricingFetcher({ offline: true });
const pricing = await Result.unwrap(fetcher.fetchModelPricing());
expect(pricing.size).toBeGreaterThan(0);
});

it('supports legacy boolean signature', async () => {
using fetcher = new PricingFetcher(true);
const pricing = await Result.unwrap(fetcher.fetchModelPricing());
expect(pricing.size).toBeGreaterThan(0);
});

it('calculates cost for Claude model tokens', async () => {
using fetcher = new PricingFetcher(true);
using fetcher = new PricingFetcher({ offline: true });
const pricing = await Result.unwrap(fetcher.getModelPricing('claude-sonnet-4-20250514'));
const cost = fetcher.calculateCostFromPricing(
{
Expand Down
16 changes: 14 additions & 2 deletions apps/ccusage/src/_shared-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ export const sharedArgs = {
type: 'boolean',
negatable: true,
short: 'O',
description: 'Use cached pricing data for Claude models instead of fetching from API',
description: 'Use bundled offline pricing data (ignores disk cache and network)',
default: false,
},
refreshPricing: {
type: 'boolean',
short: 'R',
description: 'Force refresh pricing data from network (ignores 24-hour disk cache)',
default: false,
},
color: {
Expand Down Expand Up @@ -107,7 +113,13 @@ export const sharedArgs = {
},
compact: {
type: 'boolean',
description: 'Force compact mode for narrow displays (better for screenshots)',
description: '[Deprecated: use --visual] Force compact mode for narrow displays',
default: false,
},
visual: {
type: 'boolean',
short: 'V',
description: 'Visual output mode with compact table, sparklines, and heatmap',
default: false,
},
} as const satisfies Args;
Expand Down
Loading