From 2b3dd5f580de22c6d7eb46de58f7dd21e611a615 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 13:19:01 -0700 Subject: [PATCH 01/12] feat(omni): add unified usage aggregation --- OMNI_PLAN.md | 814 +++++++++++++++++++++++++ apps/codex/package.json | 20 + apps/codex/src/daily-report.ts | 4 +- apps/codex/src/monthly-report.ts | 4 +- apps/codex/tsdown.config.ts | 10 +- apps/omni/CLAUDE.md | 64 ++ apps/omni/eslint.config.js | 16 + apps/omni/package.json | 88 +++ apps/omni/src/_consts.ts | 21 + apps/omni/src/_normalizers/claude.ts | 71 +++ apps/omni/src/_normalizers/codex.ts | 78 +++ apps/omni/src/_normalizers/index.ts | 8 + apps/omni/src/_normalizers/opencode.ts | 70 +++ apps/omni/src/_normalizers/pi.ts | 76 +++ apps/omni/src/_types.ts | 80 +++ apps/omni/src/commands/_shared.ts | 39 ++ apps/omni/src/commands/daily.ts | 174 ++++++ apps/omni/src/commands/index.ts | 3 + apps/omni/src/commands/monthly.ts | 171 ++++++ apps/omni/src/commands/session.ts | 185 ++++++ apps/omni/src/data-aggregator.ts | 566 +++++++++++++++++ apps/omni/src/index.ts | 6 + apps/omni/src/logger.ts | 7 + apps/omni/src/run.ts | 29 + apps/omni/tsconfig.json | 25 + apps/omni/tsdown.config.ts | 15 + apps/omni/vitest.config.ts | 15 + apps/opencode/package.json | 16 + apps/opencode/src/commands/daily.ts | 49 +- apps/opencode/src/commands/monthly.ts | 49 +- apps/opencode/src/commands/session.ts | 66 +- apps/opencode/src/daily-report.ts | 63 ++ apps/opencode/src/monthly-report.ts | 63 ++ apps/opencode/src/session-report.ts | 77 +++ apps/opencode/tsdown.config.ts | 8 +- apps/pi/package.json | 2 + apps/pi/tsdown.config.ts | 2 +- eslint.config.js | 2 +- pnpm-lock.yaml | 242 ++++++++ 39 files changed, 3137 insertions(+), 161 deletions(-) create mode 100644 OMNI_PLAN.md create mode 100644 apps/omni/CLAUDE.md create mode 100644 apps/omni/eslint.config.js create mode 100644 apps/omni/package.json create mode 100644 apps/omni/src/_consts.ts create mode 100644 apps/omni/src/_normalizers/claude.ts create mode 100644 apps/omni/src/_normalizers/codex.ts create mode 100644 apps/omni/src/_normalizers/index.ts create mode 100644 apps/omni/src/_normalizers/opencode.ts create mode 100644 apps/omni/src/_normalizers/pi.ts create mode 100644 apps/omni/src/_types.ts create mode 100644 apps/omni/src/commands/_shared.ts create mode 100644 apps/omni/src/commands/daily.ts create mode 100644 apps/omni/src/commands/index.ts create mode 100644 apps/omni/src/commands/monthly.ts create mode 100644 apps/omni/src/commands/session.ts create mode 100644 apps/omni/src/data-aggregator.ts create mode 100644 apps/omni/src/index.ts create mode 100644 apps/omni/src/logger.ts create mode 100644 apps/omni/src/run.ts create mode 100644 apps/omni/tsconfig.json create mode 100644 apps/omni/tsdown.config.ts create mode 100644 apps/omni/vitest.config.ts create mode 100644 apps/opencode/src/daily-report.ts create mode 100644 apps/opencode/src/monthly-report.ts create mode 100644 apps/opencode/src/session-report.ts diff --git a/OMNI_PLAN.md b/OMNI_PLAN.md new file mode 100644 index 00000000..037c3213 --- /dev/null +++ b/OMNI_PLAN.md @@ -0,0 +1,814 @@ +# @ccusage/omni Implementation Plan + +> Unified usage tracking across all AI coding assistants + +## Overview + +**Goal:** Create a new `@ccusage/omni` package that aggregates usage data from all existing ccusage CLI tools into a single, unified view. + +**Supported Sources (v1):** +| Source | Package | Data Directory | Env Override | +|--------|---------|----------------|--------------| +| Claude Code | `ccusage` | `~/.claude/projects/` or `~/.config/claude/projects/` | `CLAUDE_CONFIG_DIR` | +| OpenAI Codex | `@ccusage/codex` | `~/.codex/sessions/` | `CODEX_HOME` | +| OpenCode | `@ccusage/opencode` | `~/.local/share/opencode/storage/message/` | `OPENCODE_DATA_DIR` | +| Pi-agent | `@ccusage/pi` | `~/.pi/agent/sessions/` | `PI_AGENT_DIR` | + +> **Note:** Amp (`@ccusage/amp`) is excluded from v1 due to significant schema/semantics divergence (credits-based billing, different totalTokens calculation, different field names). Amp support is planned for a future version. + +**Usage:** + +```bash +npx @ccusage/omni@latest daily # Combined daily report +npx @ccusage/omni@latest monthly # Combined monthly report +npx @ccusage/omni@latest session # Combined session report +``` + +--- + +## Key Design Decisions + +These decisions have been confirmed through review: + +1. **Data Access Strategy:** Add exports to each app (Option A) + - Least disruptive approach + - Requires adding `exports` to each app's `package.json` + - Requires updating `tsdown.config.ts` to build exported files + +2. **Totals Semantics:** Source-faithful (Option A) + - Omni totals match each individual CLI exactly + - Grand total row shows **cost only** (comparable across sources) + - Token totals shown per-source only (not summed across sources with different semantics) + +3. **`--breakdown` Flag:** Omit for v1 (Option C) + - Only Claude and Pi support `--breakdown` + - Show models list in output instead + - Can add `--breakdown` later when all sources support it + +4. **Amp Exclusion:** Removed from v1 scope + - Different billing model (credits vs subscription) + - Different totalTokens semantics (cache excluded) + - Different field names throughout + - Planned for future version + +--- + +## Data Access Architecture + +### Current State of Each App + +| App | Has Daily Loader | Has Report Builder | tsdown Builds | Needs Changes | +| -------- | --------------------------- | ----------------------- | ------------------- | ---------------------------- | +| ccusage | ✅ `loadDailyUsageData()` | Built-in | `./src/*.ts` | None (already exports) | +| codex | ❌ Raw only | ✅ `buildDailyReport()` | `src/index.ts` only | Add exports + tsdown entries | +| opencode | ❌ Raw only | ❌ In-command | `src/index.ts` only | Add report builder + exports | +| pi | ✅ `loadPiAgentDailyData()` | Built-in | `src/index.ts` only | Add exports + tsdown entries | + +### Required Changes Per App + +#### `@ccusage/codex` + +**package.json** - Add exports: + +```json +{ + "exports": { + ".": "./src/index.ts", + "./data-loader": "./src/data-loader.ts", + "./daily-report": "./src/daily-report.ts", + "./monthly-report": "./src/monthly-report.ts", + "./session-report": "./src/session-report.ts", + "./types": "./src/_types.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./data-loader": "./dist/data-loader.js", + "./daily-report": "./dist/daily-report.js", + "./monthly-report": "./dist/monthly-report.js", + "./session-report": "./dist/session-report.js", + "./types": "./dist/_types.js", + "./package.json": "./package.json" + } + } +} +``` + +**tsdown.config.ts** - Add entry points: + +```typescript +entry: [ + 'src/index.ts', + 'src/data-loader.ts', + 'src/daily-report.ts', + 'src/monthly-report.ts', + 'src/session-report.ts', + 'src/_types.ts', +], +``` + +#### `@ccusage/pi` + +**package.json** - Add exports: + +```json +{ + "exports": { + ".": "./src/index.ts", + "./data-loader": "./src/data-loader.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./data-loader": "./dist/data-loader.js", + "./package.json": "./package.json" + } + } +} +``` + +Note: Pi's types (`DailyUsageWithSource`, `SessionUsageWithSource`, `MonthlyUsageWithSource`) are defined and exported from `data-loader.ts`, not a separate types file. + +**tsdown.config.ts** - Add entry points: + +```typescript +entry: [ + 'src/index.ts', + 'src/data-loader.ts', +], +``` + +#### `@ccusage/opencode` + +This app needs **new report builder functions** similar to Codex's pattern. + +**Naming Convention:** + +- Report builders should be named `buildDailyReport`, `buildMonthlyReport`, `buildSessionReport` +- Return types should be `DailyReportRow`, `MonthlyReportRow`, `SessionReportRow` +- Types are exported from the report builder files + +**Required Changes:** + +1. Create `daily-report.ts`, `monthly-report.ts`, `session-report.ts` +2. Extract grouping logic from commands into these files +3. Export report row types from each report builder file +4. Add exports and tsdown entries + +**OpenCode Report Row Types:** + +```typescript +// daily-report.ts +export type DailyReportRow = { + date: string; // YYYY-MM-DD + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; // input + output + cache (additive) + totalCost: number; + modelsUsed: string[]; +}; + +// monthly-report.ts +export type MonthlyReportRow = { + month: string; // YYYY-MM + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + modelsUsed: string[]; +}; + +// session-report.ts +export type SessionReportRow = { + sessionID: string; // Note: uppercase ID (matches current CLI output) + sessionTitle: string; + parentID: string | null; // Note: uppercase ID (matches current CLI output) + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + modelsUsed: string[]; + lastActivity: string; // ISO timestamp +}; +``` + +### What Omni Will Import + +```typescript +import type { DailyReportRow as CodexDailyReportRow } from '@ccusage/codex/types'; +import type { DailyReportRow as OpenCodeDailyReportRow } from '@ccusage/opencode/daily-report'; + +import type { DailyUsageWithSource as PiDailyUsage } from '@ccusage/pi/data-loader'; +import type { DailyUsage } from 'ccusage/data-loader'; +import { buildDailyReport as buildCodexDailyReport } from '@ccusage/codex/daily-report'; + +// Codex - after adding exports (types already in _types.ts) +import { loadTokenUsageEvents } from '@ccusage/codex/data-loader'; +// OpenCode - after adding report builders + exports +import { buildDailyReport as buildOpenCodeDailyReport } from '@ccusage/opencode/daily-report'; + +// Pi - after adding exports (types already in data-loader.ts) +import { loadPiAgentDailyData } from '@ccusage/pi/data-loader'; +// Claude - already exports everything +import { loadDailyUsageData } from 'ccusage/data-loader'; +``` + +--- + +## Architecture + +### Directory Structure + +``` +apps/omni/ +├── src/ +│ ├── index.ts # CLI entry point (gunshi) +│ ├── run.ts # CLI runner setup +│ ├── logger.ts # Logger instance +│ ├── _types.ts # Unified type definitions +│ ├── _consts.ts # Constants (source names, colors) +│ ├── _normalizers/ # Per-source data normalizers +│ │ ├── index.ts # Re-exports all normalizers +│ │ ├── claude.ts # Claude Code normalizer +│ │ ├── codex.ts # Codex normalizer (special handling) +│ │ ├── opencode.ts # OpenCode normalizer +│ │ └── pi.ts # Pi-agent normalizer +│ ├── data-aggregator.ts # Main aggregation logic +│ └── commands/ +│ ├── index.ts # Command exports +│ ├── daily.ts # Combined daily report +│ ├── monthly.ts # Combined monthly report +│ └── session.ts # Combined session report +├── package.json +├── tsconfig.json +├── tsdown.config.ts +├── vitest.config.ts +├── eslint.config.js +└── CLAUDE.md +``` + +### Dependencies + +```json +{ + "devDependencies": { + "ccusage": "workspace:*", + "@ccusage/codex": "workspace:*", + "@ccusage/opencode": "workspace:*", + "@ccusage/pi": "workspace:*", + "@ccusage/internal": "workspace:*", + "@ccusage/terminal": "workspace:*", + "@praha/byethrow": "catalog:runtime", + "gunshi": "catalog:runtime", + "picocolors": "catalog:runtime", + "valibot": "catalog:runtime", + "type-fest": "catalog:runtime", + "es-toolkit": "catalog:runtime", + "fast-sort": "catalog:runtime", + "vitest": "catalog:testing", + "fs-fixture": "catalog:testing", + "tsdown": "catalog:build", + "clean-pkg-json": "catalog:release", + "eslint": "catalog:lint", + "@ryoppippi/eslint-config": "catalog:lint", + "@typescript/native-preview": "catalog:types" + } +} +``` + +--- + +## Type Definitions + +### Unified Types (`_types.ts`) + +```typescript +import type { TupleToUnion } from 'type-fest'; + +/** + * Supported data sources (v1) + */ +export const Sources = ['claude', 'codex', 'opencode', 'pi'] as const; +export type Source = TupleToUnion; + +/** + * Unified token usage (normalized across all sources) + * + * IMPORTANT: Token semantics differ by source - totals are SOURCE-FAITHFUL: + * - Claude/OpenCode/Pi: totalTokens = input + output + cacheRead + cacheCreation + * - Codex: totalTokens = input + output (cache is subset of input, NOT additive) + * + * The normalizers preserve each source's native totalTokens calculation. + * Grand totals should show COST ONLY since token semantics are not comparable. + */ +export type UnifiedTokenUsage = { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; // Source-faithful, NOT recalculated +}; + +/** + * Unified daily usage entry + */ +export type UnifiedDailyUsage = UnifiedTokenUsage & { + source: Source; + date: string; // YYYY-MM-DD + costUSD: number; + models: string[]; +}; + +/** + * Unified monthly usage entry + */ +export type UnifiedMonthlyUsage = UnifiedTokenUsage & { + source: Source; + month: string; // YYYY-MM + costUSD: number; + models: string[]; +}; + +/** + * Unified session usage entry + */ +export type UnifiedSessionUsage = UnifiedTokenUsage & { + source: Source; + sessionId: string; + displayName: string; // Session name or project path + firstTimestamp: string; + lastTimestamp: string; + costUSD: number; + models: string[]; +}; + +/** + * Aggregated totals by source + */ +export type SourceTotals = { + source: Source; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; // Source-faithful + costUSD: number; +}; + +/** + * Combined report totals + * NOTE: Only costUSD is summed across sources. Token totals are per-source only. + */ +export type CombinedTotals = { + costUSD: number; // Sum across all sources (comparable) + bySource: SourceTotals[]; // Per-source breakdown with tokens +}; +``` + +### Field Mapping Reference + +| Unified Field | Claude | Codex | OpenCode | Pi | +| --------------------- | --------------------- | --------------------- | --------------------- | --------------------- | +| `inputTokens` | `inputTokens` | `inputTokens` | `inputTokens` | `inputTokens` | +| `outputTokens` | `outputTokens` | `outputTokens` | `outputTokens` | `outputTokens` | +| `cacheReadTokens` | `cacheReadTokens` | `cachedInputTokens`\* | `cacheReadTokens` | `cacheReadTokens` | +| `cacheCreationTokens` | `cacheCreationTokens` | `0` | `cacheCreationTokens` | `cacheCreationTokens` | +| `totalTokens` | input+output+cache | `totalTokens`\*\* | input+output+cache | input+output+cache | +| `costUSD` | `totalCost` | `costUSD` | `totalCost` | `totalCost` | + +**\* Codex Note:** `cachedInputTokens` is a **subset** of `inputTokens`, not additive. + +**\*\* Codex totalTokens:** `totalTokens = input + output` (cache is subset, not added separately) + +--- + +## Token Normalization Strategy + +### Source-Faithful Approach + +Each normalizer preserves the source's native `totalTokens` calculation: + +**`_normalizers/claude.ts`** + +```typescript +import type { DailyUsage } from 'ccusage/data-loader'; +import type { UnifiedDailyUsage } from '../_types.ts'; + +export function normalizeClaudeDaily(data: DailyUsage): UnifiedDailyUsage { + return { + source: 'claude', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + // Claude includes cache in total + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} +``` + +**`_normalizers/codex.ts`** + +```typescript +import type { DailyReportRow } from '@ccusage/codex/types'; +import type { UnifiedDailyUsage } from '../_types.ts'; + +export function normalizeCodexDaily(data: DailyReportRow): UnifiedDailyUsage { + return { + source: 'codex', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + // Codex: cachedInputTokens is subset of inputTokens + cacheReadTokens: data.cachedInputTokens, + cacheCreationTokens: 0, + // Source-faithful: use Codex's totalTokens directly (input + output) + totalTokens: data.totalTokens, + costUSD: data.costUSD, + models: Object.keys(data.models), + }; +} +``` + +**`_normalizers/opencode.ts`** + +```typescript +import type { DailyReportRow } from '@ccusage/opencode/daily-report'; +import type { UnifiedDailyUsage } from '../_types.ts'; + +export function normalizeOpenCodeDaily(data: DailyReportRow): UnifiedDailyUsage { + return { + source: 'opencode', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + // OpenCode includes cache in total + totalTokens: data.totalTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} +``` + +**`_normalizers/pi.ts`** + +```typescript +import type { DailyUsageWithSource } from '@ccusage/pi/data-loader'; +import type { UnifiedDailyUsage } from '../_types.ts'; + +export function normalizePiDaily(data: DailyUsageWithSource): UnifiedDailyUsage { + return { + source: 'pi', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + // Pi includes cache in total + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, // Pi uses totalCost, not costUSD + models: data.modelsUsed, + }; +} +``` + +--- + +## CLI Interface Design + +### Common Flags + +| Flag | Short | Description | Notes | +| ------------ | ----- | ------------------------------------------ | ---------------------- | +| `--json` | `-j` | Output in JSON format | All sources | +| `--sources` | `-s` | Comma-separated list of sources to include | All sources | +| `--compact` | `-c` | Force compact table mode | All sources | +| `--since` | | Start date filter (YYYY-MM-DD) | Claude, Codex, Pi only | +| `--until` | | End date filter (YYYY-MM-DD) | Claude, Codex, Pi only | +| `--days` | `-d` | Show last N days | Claude, Codex, Pi only | +| `--timezone` | | Timezone for date display | Claude, Codex, Pi only | +| `--locale` | | Locale for number/date formatting | Claude, Codex, Pi only | +| `--offline` | | Use cached pricing data | Claude, Codex only | + +**Notes:** + +- `--breakdown` is intentionally omitted from v1. Models are shown in a column instead. +- `--offline` is passed only to Claude/Codex loaders until other sources support offline pricing. +- `--since`, `--until`, `--days`, `--timezone`, `--locale` are passed only to Claude/Codex/Pi loaders. OpenCode returns all data (filtering can be added in a future version). + +### Example Commands + +```bash +# All sources, daily report +npx @ccusage/omni@latest daily + +# Only Claude and Codex +npx @ccusage/omni@latest daily --sources claude,codex + +# JSON output +npx @ccusage/omni@latest daily --json + +# Last 7 days +npx @ccusage/omni@latest daily --days 7 + +# With date range filter +npx @ccusage/omni@latest daily --since 2026-01-01 --until 2026-01-15 +``` + +### Table Output Design + +**Daily Report:** + +``` +╔══════════════════════════════════════════════════════════════════════════════════════╗ +║ Omni Usage Report - Daily (All Sources) ║ +╚══════════════════════════════════════════════════════════════════════════════════════╝ + +┌──────────┬────────────┬─────────────┬──────────────┬───────────┬──────────┬──────────┐ +│ Source │ Date │ Input │ Output │ Cache │ Cost │ Models │ +├──────────┼────────────┼─────────────┼──────────────┼───────────┼──────────┼──────────┤ +│ Claude │ 2026-01-16 │ 1,234,567 │ 456,789 │ 789,012 │ $12.34 │ sonnet-4 │ +│ Codex │ 2026-01-16 │ 987,654 │ 321,098 │ 654,321† │ $8.76 │ gpt-5 │ +│ OpenCode │ 2026-01-16 │ 543,210 │ 123,456 │ 234,567 │ $5.43 │ sonnet-4 │ +│ Pi │ 2026-01-16 │ 111,111 │ 22,222 │ 33,333 │ $1.50 │ sonnet-4 │ +│ Claude │ 2026-01-15 │ 1,111,111 │ 222,222 │ 333,333 │ $10.00 │ sonnet-4 │ +│ Codex │ 2026-01-15 │ 444,444 │ 555,555 │ 666,666† │ $7.50 │ gpt-5 │ +└──────────┴────────────┴─────────────┴──────────────┴───────────┴──────────┴──────────┘ + +† Codex cache is subset of input (not additive) + +By Source: Cost + • Claude ...................... $22.34 + • Codex ....................... $16.26 + • OpenCode .................... $5.43 + • Pi .......................... $1.50 + ─────── + TOTAL $45.53 +``` + +**Key Design Points:** + +- Token grand totals are NOT shown (different semantics per source) +- Cost grand total IS shown (comparable across sources) +- Per-source breakdown shows individual token totals +- Footnote explains Codex cache semantics + +**Cache Column Definition:** + +- Cache = `cacheReadTokens + cacheCreationTokens` (sum of both) +- For Codex, cache is still shown but marked with † to indicate it's a subset of input (not additive) + +**JSON Output Structure:** + +```json +{ + "daily": [ + { + "source": "claude", + "date": "2026-01-16", + "inputTokens": 1234567, + "outputTokens": 456789, + "cacheReadTokens": 789012, + "cacheCreationTokens": 0, + "totalTokens": 2480368, + "costUSD": 12.34, + "models": ["claude-sonnet-4-20250514"] + }, + { + "source": "codex", + "date": "2026-01-16", + "inputTokens": 987654, + "outputTokens": 321098, + "cacheReadTokens": 654321, + "cacheCreationTokens": 0, + "totalTokens": 1308752, + "costUSD": 8.76, + "models": ["gpt-5"] + } + ], + "totals": { + "costUSD": 45.53, + "bySource": [ + { + "source": "claude", + "inputTokens": 2345678, + "outputTokens": 679011, + "cacheReadTokens": 1122345, + "cacheCreationTokens": 0, + "totalTokens": 4147034, + "costUSD": 22.34 + }, + { + "source": "codex", + "inputTokens": 1432098, + "outputTokens": 876653, + "cacheReadTokens": 1320987, + "cacheCreationTokens": 0, + "totalTokens": 2308751, + "costUSD": 16.26 + }, + { + "source": "opencode", + "inputTokens": 543210, + "outputTokens": 123456, + "cacheReadTokens": 200000, + "cacheCreationTokens": 34567, + "totalTokens": 901233, + "costUSD": 5.43 + }, + { + "source": "pi", + "inputTokens": 111111, + "outputTokens": 22222, + "cacheReadTokens": 33333, + "cacheCreationTokens": 0, + "totalTokens": 166666, + "costUSD": 1.5 + } + ] + } +} +``` + +--- + +## CLI Entry Point + +**`run.ts`** - Following existing Gunshi patterns: + +```typescript +import process from 'node:process'; +import { cli } from 'gunshi'; +import { description, name, version } from '../package.json'; +import { dailyCommand } from './commands/daily.ts'; +import { monthlyCommand } from './commands/monthly.ts'; +import { sessionCommand } from './commands/session.ts'; + +export async function run(): Promise { + const args = process.argv.slice(2); + + // Strip binary name if present (matches existing CLI patterns) + const filteredArgs = args[0] === name ? args.slice(1) : args; + + await cli(filteredArgs, dailyCommand, { + name, + description, + version, + subCommands: { + daily: dailyCommand, + monthly: monthlyCommand, + session: sessionCommand, + }, + }); +} +``` + +**`index.ts`**: + +```typescript +#!/usr/bin/env node +import { run } from './run.ts'; + +await run(); +``` + +--- + +## Testing Strategy + +### Unit Tests (In-Source) + +1. **Normalizer tests** - Verify each normalizer correctly transforms source data + - **Critical: Test source-faithful totalTokens** - Codex uses input+output only +2. **Aggregator tests** - Verify data is properly combined and sorted +3. **Totals calculation tests** - Verify cost totals are summed, token totals are per-source only + +### Test File Structure + +Tests will be in-source using `if (import.meta.vitest != null)` blocks per project convention. + +--- + +## Edge Cases & Error Handling + +| Scenario | Handling | +| ------------------------------ | ---------------------------------------------------- | +| Source has no data | Skip silently, continue with other sources | +| Source directory doesn't exist | Skip silently, log at debug level | +| Source data fails to parse | Skip that source, log warning | +| All sources empty | Display "No usage data found" message | +| Single source requested | Works like running that tool directly | +| Network error (pricing) | Use cached/fallback pricing | +| Codex missing totalTokens | Calculate as `input + output` (per Codex convention) | + +--- + +## Implementation Checklist + +### Phase 0: Prerequisite Changes to Other Apps + +- [ ] **@ccusage/codex** + - [ ] Add exports to `package.json` (include `./types` → `_types.ts`) + - [ ] Update `tsdown.config.ts` entry points (add `_types.ts`) + +- [ ] **@ccusage/pi** + - [ ] Add exports to `package.json` (types are in `data-loader.ts`, not `_types.ts`) + - [ ] Update `tsdown.config.ts` entry points + +- [ ] **@ccusage/opencode** + - [ ] Create `daily-report.ts` (extract from command, export `DailyReportRow` type) + - [ ] Create `monthly-report.ts` (extract from command, export `MonthlyReportRow` type) + - [ ] Create `session-report.ts` (extract from command, export `SessionReportRow` type) + - [ ] Add exports to `package.json` + - [ ] Update `tsdown.config.ts` entry points + +### Phase 1: Omni Scaffolding + +- [ ] Create `apps/omni/` directory structure +- [ ] Create `package.json` with dependencies +- [ ] Create config files (tsconfig, tsdown, vitest, eslint) +- [ ] Create `CLAUDE.md` + +### Phase 2: Core Infrastructure + +- [ ] Create `_types.ts` with unified types (include token semantics docs) +- [ ] Create `_consts.ts` with source colors/labels +- [ ] Create `logger.ts` + +### Phase 3: Normalizers + +- [ ] Create `_normalizers/claude.ts` +- [ ] Create `_normalizers/codex.ts` (source-faithful totals) +- [ ] Create `_normalizers/opencode.ts` +- [ ] Create `_normalizers/pi.ts` +- [ ] Create `_normalizers/index.ts` +- [ ] Add unit tests for each normalizer + +### Phase 4: Data Aggregator + +- [ ] Create `data-aggregator.ts` +- [ ] Implement `loadCombinedDailyData()` +- [ ] Implement `loadCombinedMonthlyData()` +- [ ] Implement `loadCombinedSessionData()` +- [ ] Add unit tests + +### Phase 5: Commands + +- [ ] Create `commands/daily.ts` +- [ ] Create `commands/monthly.ts` +- [ ] Create `commands/session.ts` +- [ ] Create `commands/index.ts` + +### Phase 6: CLI Entry + +- [ ] Create `index.ts` +- [ ] Create `run.ts` (follow existing Gunshi patterns) +- [ ] Test CLI execution + +### Phase 7: Release + +- [ ] Run `pnpm run format` +- [ ] Run `pnpm typecheck` +- [ ] Run `pnpm run test` +- [ ] Build and test locally +- [ ] Submit PR + +--- + +## Future Enhancements (Post v1) + +1. **Amp support** - Add `@ccusage/amp` once schema/semantics alignment is resolved +2. **`--breakdown`** - Add once all sources support per-model breakdowns +3. **`--group-by-date`** - Aggregate all sources per date into single row +4. **Configurable source paths** - Override default directories via flags +5. **Trend analysis** - Compare usage across time periods +6. **Export formats** - CSV, HTML report generation +7. **MCP integration** - Add omni tools to `@ccusage/mcp` + +--- + +## Notes + +- All dependencies should be `devDependencies` (bundled app pattern) +- Follow existing code style (tabs, double quotes, `.ts` imports) +- Use `@praha/byethrow` Result type for error handling +- Use `gunshi` for CLI framework +- Use `@ccusage/terminal` for table rendering +- No `console.log` - use logger instead +- Vitest globals enabled - no imports needed for `describe`, `it`, `expect` +- `type-fest` is already used in ccusage for `TupleToUnion` - follow same pattern diff --git a/apps/codex/package.json b/apps/codex/package.json index 7461ac68..d8239125 100644 --- a/apps/codex/package.json +++ b/apps/codex/package.json @@ -15,6 +15,16 @@ "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, + "exports": { + ".": "./src/index.ts", + "./data-loader": "./src/data-loader.ts", + "./daily-report": "./src/daily-report.ts", + "./monthly-report": "./src/monthly-report.ts", + "./session-report": "./src/session-report.ts", + "./pricing": "./src/pricing.ts", + "./types": "./src/_types.ts", + "./package.json": "./package.json" + }, "main": "./dist/index.js", "module": "./dist/index.js", "bin": { @@ -26,6 +36,16 @@ "publishConfig": { "bin": { "ccusage-codex": "./dist/index.js" + }, + "exports": { + ".": "./dist/index.js", + "./data-loader": "./dist/data-loader.js", + "./daily-report": "./dist/daily-report.js", + "./monthly-report": "./dist/monthly-report.js", + "./session-report": "./dist/session-report.js", + "./pricing": "./dist/pricing.js", + "./types": "./dist/_types.js", + "./package.json": "./package.json" } }, "engines": { diff --git a/apps/codex/src/daily-report.ts b/apps/codex/src/daily-report.ts index c44a34a9..3ea03b94 100644 --- a/apps/codex/src/daily-report.ts +++ b/apps/codex/src/daily-report.ts @@ -15,6 +15,7 @@ export type DailyReportOptions = { since?: string; until?: string; pricingSource: PricingSource; + formatDate?: boolean; }; function createSummary(date: string, initialTimestamp: string): DailyUsageSummary { @@ -40,6 +41,7 @@ export async function buildDailyReport( const since = options.since; const until = options.until; const pricingSource = options.pricingSource; + const formatDate = options.formatDate ?? true; const summaries = new Map(); @@ -107,7 +109,7 @@ export async function buildDailyReport( } rows.push({ - date: formatDisplayDate(summary.date, locale, timezone), + date: formatDate ? formatDisplayDate(summary.date, locale, timezone) : summary.date, inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, diff --git a/apps/codex/src/monthly-report.ts b/apps/codex/src/monthly-report.ts index b29e03ae..967a2cd8 100644 --- a/apps/codex/src/monthly-report.ts +++ b/apps/codex/src/monthly-report.ts @@ -15,6 +15,7 @@ export type MonthlyReportOptions = { since?: string; until?: string; pricingSource: PricingSource; + formatDate?: boolean; }; function createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary { @@ -40,6 +41,7 @@ export async function buildMonthlyReport( const since = options.since; const until = options.until; const pricingSource = options.pricingSource; + const formatDate = options.formatDate ?? true; const summaries = new Map(); @@ -108,7 +110,7 @@ export async function buildMonthlyReport( } rows.push({ - month: formatDisplayMonth(summary.month, locale, timezone), + month: formatDate ? formatDisplayMonth(summary.month, locale, timezone) : summary.month, inputTokens: summary.inputTokens, cachedInputTokens: summary.cachedInputTokens, outputTokens: summary.outputTokens, diff --git a/apps/codex/tsdown.config.ts b/apps/codex/tsdown.config.ts index 08f5e4e5..1c0093b1 100644 --- a/apps/codex/tsdown.config.ts +++ b/apps/codex/tsdown.config.ts @@ -2,7 +2,15 @@ import { defineConfig } from 'tsdown'; import Macros from 'unplugin-macros/rolldown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: [ + 'src/index.ts', + 'src/data-loader.ts', + 'src/daily-report.ts', + 'src/monthly-report.ts', + 'src/session-report.ts', + 'src/pricing.ts', + 'src/_types.ts', + ], outDir: 'dist', format: 'esm', clean: true, diff --git a/apps/omni/CLAUDE.md b/apps/omni/CLAUDE.md new file mode 100644 index 00000000..556422b6 --- /dev/null +++ b/apps/omni/CLAUDE.md @@ -0,0 +1,64 @@ +# Omni CLI Notes + +## Goal + +- `@ccusage/omni` aggregates usage data from Claude Code, Codex, OpenCode, and Pi-agent into a single report. +- Amp is intentionally excluded from v1 due to schema and billing differences. + +## Data Sources + +| Source | Default Directory | Env Override | +| ------------ | ------------------------------------------------------ | ------------------- | +| Claude Code | `~/.config/claude/projects/` and `~/.claude/projects/` | `CLAUDE_CONFIG_DIR` | +| OpenAI Codex | `~/.codex/sessions/` | `CODEX_HOME` | +| OpenCode | `~/.local/share/opencode/storage/message/` | `OPENCODE_DATA_DIR` | +| Pi-agent | `~/.pi/agent/sessions/` | `PI_AGENT_DIR` | + +## Token Semantics + +- Totals are source-faithful. +- Claude/OpenCode/Pi: `totalTokens = input + output + cacheRead + cacheCreation`. +- Codex: `totalTokens = input + output` (cache is a subset of input and is not additive). +- Omni grand totals only sum **cost** across sources. + +## CLI Usage + +```bash +npx @ccusage/omni@latest daily +npx @ccusage/omni@latest monthly +npx @ccusage/omni@latest session +``` + +Common flags: + +- `--json` / `-j` JSON output +- `--sources` / `-s` Comma-separated list (claude,codex,opencode,pi) +- `--compact` / `-c` Force compact table +- `--since`, `--until` Date filters (YYYY-MM-DD or YYYYMMDD) +- `--days` / `-d` Last N days +- `--timezone` Timezone for date grouping +- `--locale` Locale for formatting +- `--offline` Use cached pricing data (Claude/Codex) + +Notes: + +- `--since`/`--until`/`--days` are passed to Claude, Codex, and Pi. OpenCode currently returns all data (future filtering). +- Codex rows mark cache with a dagger to indicate subset-of-input semantics. + +## Architecture + +- Normalizers live in `src/_normalizers/`. +- Aggregation logic is in `src/data-aggregator.ts`. +- CLI entry is `src/index.ts` and `src/run.ts` (Gunshi-based). + +## Development + +- Omni is a bundled CLI; keep runtime deps in `devDependencies`. +- Use `@ccusage/terminal` for tables and `@ccusage/internal` for logging/pricing. +- Prefer `@praha/byethrow` Result type when adding new error handling. + +## Testing + +- In-source vitest blocks using `if (import.meta.vitest != null)`. +- Vitest globals are enabled: use `describe`, `it`, `expect` without imports. +- Never use dynamic `await import()` in tests or runtime code. diff --git a/apps/omni/eslint.config.js b/apps/omni/eslint.config.js new file mode 100644 index 00000000..bf7ac51b --- /dev/null +++ b/apps/omni/eslint.config.js @@ -0,0 +1,16 @@ +import { ryoppippi } from '@ryoppippi/eslint-config'; + +/** @type {import('eslint').Linter.FlatConfig[]} */ +const config = ryoppippi( + { + type: 'app', + stylistic: false, + }, + { + rules: { + 'test/no-importing-vitest-globals': 'error', + }, + }, +); + +export default config; diff --git a/apps/omni/package.json b/apps/omni/package.json new file mode 100644 index 00000000..a606f8dd --- /dev/null +++ b/apps/omni/package.json @@ -0,0 +1,88 @@ +{ + "name": "@ccusage/omni", + "type": "module", + "version": "18.0.5", + "description": "Unified usage tracking across AI coding assistants", + "author": "ryoppippi", + "license": "MIT", + "funding": "https://github.com/ryoppippi/ccusage?sponsor=1", + "homepage": "https://github.com/ryoppippi/ccusage#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/ryoppippi/ccusage.git", + "directory": "apps/omni" + }, + "bugs": { + "url": "https://github.com/ryoppippi/ccusage/issues" + }, + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "bin": { + "ccusage-omni": "./src/index.ts" + }, + "files": [ + "dist" + ], + "publishConfig": { + "bin": { + "ccusage-omni": "./dist/index.js" + }, + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "engines": { + "node": ">=20.19.4" + }, + "scripts": { + "build": "tsdown", + "format": "pnpm run lint --fix", + "lint": "eslint --cache .", + "prepack": "pnpm run build && clean-pkg-json", + "prerelease": "pnpm run lint && pnpm run typecheck && pnpm run build", + "start": "bun ./src/index.ts", + "test": "TZ=UTC vitest", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@ccusage/codex": "workspace:*", + "@ccusage/internal": "workspace:*", + "@ccusage/opencode": "workspace:*", + "@ccusage/pi": "workspace:*", + "@ccusage/terminal": "workspace:*", + "@praha/byethrow": "catalog:runtime", + "@ryoppippi/eslint-config": "catalog:lint", + "@typescript/native-preview": "catalog:types", + "ccusage": "workspace:*", + "clean-pkg-json": "catalog:release", + "es-toolkit": "catalog:runtime", + "eslint": "catalog:lint", + "fast-sort": "catalog:runtime", + "fs-fixture": "catalog:testing", + "gunshi": "catalog:runtime", + "picocolors": "catalog:runtime", + "tsdown": "catalog:build", + "type-fest": "catalog:runtime", + "valibot": "catalog:runtime", + "vitest": "catalog:testing" + }, + "devEngines": { + "runtime": [ + { + "name": "node", + "version": "^24.11.0", + "onFail": "download" + }, + { + "name": "bun", + "version": "^1.3.2", + "onFail": "download" + } + ] + } +} diff --git a/apps/omni/src/_consts.ts b/apps/omni/src/_consts.ts new file mode 100644 index 00000000..a3216e68 --- /dev/null +++ b/apps/omni/src/_consts.ts @@ -0,0 +1,21 @@ +import type { Source } from './_types.ts'; +import pc from 'picocolors'; + +export const SOURCE_ORDER: Source[] = ['claude', 'codex', 'opencode', 'pi']; + +export const SOURCE_LABELS: Record = { + claude: 'Claude', + codex: 'Codex', + opencode: 'OpenCode', + pi: 'Pi', +}; + +export const SOURCE_COLORS: Record string> = { + claude: pc.cyan, + codex: pc.blue, + opencode: pc.magenta, + pi: pc.green, +}; + +export const CODEX_CACHE_MARK = '\u2020'; +export const CODEX_CACHE_NOTE = `${CODEX_CACHE_MARK} Codex cache is subset of input (not additive)`; diff --git a/apps/omni/src/_normalizers/claude.ts b/apps/omni/src/_normalizers/claude.ts new file mode 100644 index 00000000..a59a3e0e --- /dev/null +++ b/apps/omni/src/_normalizers/claude.ts @@ -0,0 +1,71 @@ +import type { DailyUsage, MonthlyUsage, SessionUsage } from 'ccusage/data-loader'; +import type { UnifiedDailyUsage, UnifiedMonthlyUsage, UnifiedSessionUsage } from '../_types.ts'; + +export function normalizeClaudeDaily(data: DailyUsage): UnifiedDailyUsage { + return { + source: 'claude', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizeClaudeMonthly(data: MonthlyUsage): UnifiedMonthlyUsage { + return { + source: 'claude', + month: data.month, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizeClaudeSession(data: SessionUsage): UnifiedSessionUsage { + return { + source: 'claude', + sessionId: data.sessionId, + displayName: data.projectPath, + firstTimestamp: data.lastActivity, + lastTimestamp: data.lastActivity, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +if (import.meta.vitest != null) { + describe('normalizeClaudeDaily', () => { + it('preserves cache-inclusive totalTokens', () => { + const data = { + date: '2025-01-01', + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 10, + cacheReadTokens: 5, + totalCost: 1.23, + modelsUsed: ['claude-sonnet-4-20250514'], + modelBreakdowns: [], + } as unknown as DailyUsage; + + const normalized = normalizeClaudeDaily(data); + + expect(normalized.totalTokens).toBe(165); + }); + }); +} diff --git a/apps/omni/src/_normalizers/codex.ts b/apps/omni/src/_normalizers/codex.ts new file mode 100644 index 00000000..9e5c3ee4 --- /dev/null +++ b/apps/omni/src/_normalizers/codex.ts @@ -0,0 +1,78 @@ +import type { DailyReportRow, MonthlyReportRow, SessionReportRow } from '@ccusage/codex/types'; +import type { UnifiedDailyUsage, UnifiedMonthlyUsage, UnifiedSessionUsage } from '../_types.ts'; + +export function normalizeCodexDaily(data: DailyReportRow): UnifiedDailyUsage { + return { + source: 'codex', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cachedInputTokens, + cacheCreationTokens: 0, + totalTokens: data.totalTokens, + costUSD: data.costUSD, + models: Object.keys(data.models), + }; +} + +export function normalizeCodexMonthly(data: MonthlyReportRow): UnifiedMonthlyUsage { + return { + source: 'codex', + month: data.month, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cachedInputTokens, + cacheCreationTokens: 0, + totalTokens: data.totalTokens, + costUSD: data.costUSD, + models: Object.keys(data.models), + }; +} + +export function normalizeCodexSession(data: SessionReportRow): UnifiedSessionUsage { + const displayName = data.sessionFile.trim() === '' ? data.sessionId : data.sessionFile; + return { + source: 'codex', + sessionId: data.sessionId, + displayName, + firstTimestamp: data.lastActivity, + lastTimestamp: data.lastActivity, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cachedInputTokens, + cacheCreationTokens: 0, + totalTokens: data.totalTokens, + costUSD: data.costUSD, + models: Object.keys(data.models), + }; +} + +if (import.meta.vitest != null) { + describe('normalizeCodexDaily', () => { + it('keeps source totalTokens and treats cache as subset of input', () => { + const data = { + date: '2025-01-02', + inputTokens: 200, + cachedInputTokens: 50, + outputTokens: 100, + reasoningOutputTokens: 0, + totalTokens: 300, + costUSD: 2.5, + models: { + 'gpt-5': { + inputTokens: 200, + cachedInputTokens: 50, + outputTokens: 100, + reasoningOutputTokens: 0, + totalTokens: 300, + }, + }, + } satisfies DailyReportRow; + + const normalized = normalizeCodexDaily(data); + + expect(normalized.cacheReadTokens).toBe(50); + expect(normalized.totalTokens).toBe(300); + }); + }); +} diff --git a/apps/omni/src/_normalizers/index.ts b/apps/omni/src/_normalizers/index.ts new file mode 100644 index 00000000..cce60ac1 --- /dev/null +++ b/apps/omni/src/_normalizers/index.ts @@ -0,0 +1,8 @@ +export { normalizeClaudeDaily, normalizeClaudeMonthly, normalizeClaudeSession } from './claude.ts'; +export { normalizeCodexDaily, normalizeCodexMonthly, normalizeCodexSession } from './codex.ts'; +export { + normalizeOpenCodeDaily, + normalizeOpenCodeMonthly, + normalizeOpenCodeSession, +} from './opencode.ts'; +export { normalizePiDaily, normalizePiMonthly, normalizePiSession } from './pi.ts'; diff --git a/apps/omni/src/_normalizers/opencode.ts b/apps/omni/src/_normalizers/opencode.ts new file mode 100644 index 00000000..436c621f --- /dev/null +++ b/apps/omni/src/_normalizers/opencode.ts @@ -0,0 +1,70 @@ +import type { DailyReportRow } from '@ccusage/opencode/daily-report'; +import type { MonthlyReportRow } from '@ccusage/opencode/monthly-report'; +import type { SessionReportRow } from '@ccusage/opencode/session-report'; +import type { UnifiedDailyUsage, UnifiedMonthlyUsage, UnifiedSessionUsage } from '../_types.ts'; + +export function normalizeOpenCodeDaily(data: DailyReportRow): UnifiedDailyUsage { + return { + source: 'opencode', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: data.totalTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizeOpenCodeMonthly(data: MonthlyReportRow): UnifiedMonthlyUsage { + return { + source: 'opencode', + month: data.month, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: data.totalTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizeOpenCodeSession(data: SessionReportRow): UnifiedSessionUsage { + return { + source: 'opencode', + sessionId: data.sessionID, + displayName: data.sessionTitle, + firstTimestamp: data.lastActivity, + lastTimestamp: data.lastActivity, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: data.totalTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +if (import.meta.vitest != null) { + describe('normalizeOpenCodeDaily', () => { + it('preserves additive totalTokens', () => { + const data = { + date: '2025-01-03', + inputTokens: 10, + outputTokens: 20, + cacheCreationTokens: 5, + cacheReadTokens: 2, + totalTokens: 37, + totalCost: 0.25, + modelsUsed: ['claude-opus-4-20250514'], + } satisfies DailyReportRow; + + const normalized = normalizeOpenCodeDaily(data); + + expect(normalized.totalTokens).toBe(37); + }); + }); +} diff --git a/apps/omni/src/_normalizers/pi.ts b/apps/omni/src/_normalizers/pi.ts new file mode 100644 index 00000000..80f51ba8 --- /dev/null +++ b/apps/omni/src/_normalizers/pi.ts @@ -0,0 +1,76 @@ +import type { + DailyUsageWithSource, + MonthlyUsageWithSource, + SessionUsageWithSource, +} from '@ccusage/pi/data-loader'; +import type { UnifiedDailyUsage, UnifiedMonthlyUsage, UnifiedSessionUsage } from '../_types.ts'; + +export function normalizePiDaily(data: DailyUsageWithSource): UnifiedDailyUsage { + return { + source: 'pi', + date: data.date, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizePiMonthly(data: MonthlyUsageWithSource): UnifiedMonthlyUsage { + return { + source: 'pi', + month: data.month, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +export function normalizePiSession(data: SessionUsageWithSource): UnifiedSessionUsage { + return { + source: 'pi', + sessionId: data.sessionId, + displayName: data.projectPath, + firstTimestamp: data.lastActivity, + lastTimestamp: data.lastActivity, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheCreationTokens: data.cacheCreationTokens, + totalTokens: + data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, + costUSD: data.totalCost, + models: data.modelsUsed, + }; +} + +if (import.meta.vitest != null) { + describe('normalizePiDaily', () => { + it('preserves cache-inclusive totalTokens', () => { + const data = { + date: '2025-01-04', + source: 'pi-agent', + inputTokens: 40, + outputTokens: 10, + cacheCreationTokens: 3, + cacheReadTokens: 2, + totalCost: 0.5, + modelsUsed: ['[pi] claude-opus-4-5'], + modelBreakdowns: [], + } satisfies DailyUsageWithSource; + + const normalized = normalizePiDaily(data); + + expect(normalized.totalTokens).toBe(55); + }); + }); +} diff --git a/apps/omni/src/_types.ts b/apps/omni/src/_types.ts new file mode 100644 index 00000000..02d642b1 --- /dev/null +++ b/apps/omni/src/_types.ts @@ -0,0 +1,80 @@ +import type { TupleToUnion } from 'type-fest'; + +/** + * Supported data sources (v1) + */ +export const Sources = ['claude', 'codex', 'opencode', 'pi'] as const; +export type Source = TupleToUnion; + +/** + * Unified token usage (normalized across all sources) + * + * IMPORTANT: Token semantics differ by source - totals are SOURCE-FAITHFUL: + * - Claude/OpenCode/Pi: totalTokens = input + output + cacheRead + cacheCreation + * - Codex: totalTokens = input + output (cache is subset of input, NOT additive) + * + * The normalizers preserve each source's native totalTokens calculation. + * Grand totals should show COST ONLY since token semantics are not comparable. + */ +export type UnifiedTokenUsage = { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; +}; + +/** + * Unified daily usage entry + */ +export type UnifiedDailyUsage = UnifiedTokenUsage & { + source: Source; + date: string; // YYYY-MM-DD + costUSD: number; + models: string[]; +}; + +/** + * Unified monthly usage entry + */ +export type UnifiedMonthlyUsage = UnifiedTokenUsage & { + source: Source; + month: string; // YYYY-MM + costUSD: number; + models: string[]; +}; + +/** + * Unified session usage entry + */ +export type UnifiedSessionUsage = UnifiedTokenUsage & { + source: Source; + sessionId: string; + displayName: string; // Session name or project path + firstTimestamp: string; + lastTimestamp: string; + costUSD: number; + models: string[]; +}; + +/** + * Aggregated totals by source + */ +export type SourceTotals = { + source: Source; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + costUSD: number; +}; + +/** + * Combined report totals + * NOTE: Only costUSD is summed across sources. Token totals are per-source only. + */ +export type CombinedTotals = { + costUSD: number; + bySource: SourceTotals[]; +}; diff --git a/apps/omni/src/commands/_shared.ts b/apps/omni/src/commands/_shared.ts new file mode 100644 index 00000000..a6927707 --- /dev/null +++ b/apps/omni/src/commands/_shared.ts @@ -0,0 +1,39 @@ +import type { CombinedTotals, Source } from '../_types.ts'; +import { formatCurrency, formatNumber } from '@ccusage/terminal/table'; +import { CODEX_CACHE_MARK, SOURCE_COLORS, SOURCE_LABELS } from '../_consts.ts'; +import { Sources } from '../_types.ts'; + +export function formatSourceLabel(source: Source): string { + return SOURCE_COLORS[source](SOURCE_LABELS[source]); +} + +export function formatSourcesTitle(sources: Source[]): string { + if (sources.length === 0 || sources.length === Sources.length) { + return 'All Sources'; + } + + return sources.map((source) => SOURCE_LABELS[source]).join(', '); +} + +export function formatCacheValue(source: Source, cacheTokens: number): string { + const value = formatNumber(cacheTokens); + return source === 'codex' ? `${value}${CODEX_CACHE_MARK}` : value; +} + +export function formatCostSummary(totals: CombinedTotals): string { + const labels = totals.bySource.map((entry) => SOURCE_LABELS[entry.source]); + const labelWidth = Math.max('TOTAL'.length, ...labels.map((label) => label.length)); + const dotWidth = Math.max(8, labelWidth + 8); + + const lines: string[] = ['By Source (Cost)']; + for (const entry of totals.bySource) { + const label = SOURCE_LABELS[entry.source]; + const dots = '.'.repeat(Math.max(2, dotWidth - label.length)); + lines.push(` - ${label} ${dots} ${formatCurrency(entry.costUSD)}`); + } + + const totalDots = '.'.repeat(Math.max(2, dotWidth - 'TOTAL'.length)); + lines.push(` TOTAL ${totalDots} ${formatCurrency(totals.costUSD)}`); + + return lines.join('\n'); +} diff --git a/apps/omni/src/commands/daily.ts b/apps/omni/src/commands/daily.ts new file mode 100644 index 00000000..4671cf49 --- /dev/null +++ b/apps/omni/src/commands/daily.ts @@ -0,0 +1,174 @@ +import process from 'node:process'; +import { + formatCurrency, + formatDateCompact, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import { CODEX_CACHE_NOTE } from '../_consts.ts'; +import { + loadCombinedDailyData, + normalizeDateInput, + parseSources, + resolveDateRangeFromDays, +} from '../data-aggregator.ts'; +import { log, logger } from '../logger.ts'; +import { + formatCacheValue, + formatCostSummary, + formatSourceLabel, + formatSourcesTitle, +} from './_shared.ts'; + +export const dailyCommand = define({ + name: 'daily', + description: 'Show combined usage report grouped by day', + args: { + json: { + type: 'boolean', + short: 'j', + description: 'Output in JSON format', + default: false, + }, + sources: { + type: 'string', + short: 's', + description: 'Comma-separated list of sources to include', + }, + compact: { + type: 'boolean', + short: 'c', + description: 'Force compact table mode', + default: false, + }, + since: { + type: 'string', + description: 'Start date (YYYY-MM-DD or YYYYMMDD)', + }, + until: { + type: 'string', + description: 'End date (YYYY-MM-DD or YYYYMMDD)', + }, + days: { + type: 'number', + short: 'd', + description: 'Show last N days', + }, + timezone: { + type: 'string', + description: 'Timezone for date grouping', + }, + locale: { + type: 'string', + description: 'Locale for formatting', + }, + offline: { + type: 'boolean', + negatable: true, + description: 'Use cached pricing data', + default: false, + }, + }, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let sources; + let since: string | undefined; + let until: string | undefined; + + try { + sources = parseSources(ctx.values.sources); + since = normalizeDateInput(ctx.values.since); + until = normalizeDateInput(ctx.values.until); + + if (ctx.values.days != null) { + const range = resolveDateRangeFromDays(ctx.values.days, ctx.values.timezone); + since = range.since; + until = range.until; + } + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { data, totals } = await loadCombinedDailyData({ + sources, + since, + until, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + offline: ctx.values.offline, + }); + + if (data.length === 0) { + log(jsonOutput ? JSON.stringify({ daily: [], totals: null }) : 'No usage data found.'); + return; + } + + if (jsonOutput) { + log( + JSON.stringify( + { + daily: data, + totals, + }, + null, + 2, + ), + ); + return; + } + + logger.box(`Omni Usage Report - Daily (${formatSourcesTitle(sources)})`); + + const table: ResponsiveTable = new ResponsiveTable({ + head: ['Source', 'Date', 'Input', 'Output', 'Cache', 'Cost (USD)', 'Models'], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'left'], + compactHead: ['Source', 'Date', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + dateFormatter: (dateStr: string) => + formatDateCompact(dateStr, ctx.values.timezone, ctx.values.locale), + }); + + let hasCodex = false; + for (const row of data) { + const cacheTokens = row.cacheReadTokens + row.cacheCreationTokens; + if (row.source === 'codex') { + hasCodex = true; + } + + table.push([ + formatSourceLabel(row.source), + row.date, + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatCacheValue(row.source, cacheTokens), + formatCurrency(row.costUSD), + formatModelsDisplayMultiline(row.models), + ]); + } + + log(table.toString()); + + if (hasCodex) { + log(`\n${CODEX_CACHE_NOTE}`); + } + + if (totals != null) { + log(`\n${formatCostSummary(totals)}`); + } + + if (table.isCompactMode()) { + log('\nRunning in Compact Mode'); + log('Expand terminal width to see cache metrics and models'); + } + }, +}); diff --git a/apps/omni/src/commands/index.ts b/apps/omni/src/commands/index.ts new file mode 100644 index 00000000..f126c9ef --- /dev/null +++ b/apps/omni/src/commands/index.ts @@ -0,0 +1,3 @@ +export { dailyCommand } from './daily.ts'; +export { monthlyCommand } from './monthly.ts'; +export { sessionCommand } from './session.ts'; diff --git a/apps/omni/src/commands/monthly.ts b/apps/omni/src/commands/monthly.ts new file mode 100644 index 00000000..8e555ff5 --- /dev/null +++ b/apps/omni/src/commands/monthly.ts @@ -0,0 +1,171 @@ +import process from 'node:process'; +import { + formatCurrency, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import { CODEX_CACHE_NOTE } from '../_consts.ts'; +import { + loadCombinedMonthlyData, + normalizeDateInput, + parseSources, + resolveDateRangeFromDays, +} from '../data-aggregator.ts'; +import { log, logger } from '../logger.ts'; +import { + formatCacheValue, + formatCostSummary, + formatSourceLabel, + formatSourcesTitle, +} from './_shared.ts'; + +export const monthlyCommand = define({ + name: 'monthly', + description: 'Show combined usage report grouped by month', + args: { + json: { + type: 'boolean', + short: 'j', + description: 'Output in JSON format', + default: false, + }, + sources: { + type: 'string', + short: 's', + description: 'Comma-separated list of sources to include', + }, + compact: { + type: 'boolean', + short: 'c', + description: 'Force compact table mode', + default: false, + }, + since: { + type: 'string', + description: 'Start date (YYYY-MM-DD or YYYYMMDD)', + }, + until: { + type: 'string', + description: 'End date (YYYY-MM-DD or YYYYMMDD)', + }, + days: { + type: 'number', + short: 'd', + description: 'Show last N days', + }, + timezone: { + type: 'string', + description: 'Timezone for date grouping', + }, + locale: { + type: 'string', + description: 'Locale for formatting', + }, + offline: { + type: 'boolean', + negatable: true, + description: 'Use cached pricing data', + default: false, + }, + }, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let sources; + let since: string | undefined; + let until: string | undefined; + + try { + sources = parseSources(ctx.values.sources); + since = normalizeDateInput(ctx.values.since); + until = normalizeDateInput(ctx.values.until); + + if (ctx.values.days != null) { + const range = resolveDateRangeFromDays(ctx.values.days, ctx.values.timezone); + since = range.since; + until = range.until; + } + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { data, totals } = await loadCombinedMonthlyData({ + sources, + since, + until, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + offline: ctx.values.offline, + }); + + if (data.length === 0) { + log(jsonOutput ? JSON.stringify({ monthly: [], totals: null }) : 'No usage data found.'); + return; + } + + if (jsonOutput) { + log( + JSON.stringify( + { + monthly: data, + totals, + }, + null, + 2, + ), + ); + return; + } + + logger.box(`Omni Usage Report - Monthly (${formatSourcesTitle(sources)})`); + + const table: ResponsiveTable = new ResponsiveTable({ + head: ['Source', 'Month', 'Input', 'Output', 'Cache', 'Cost (USD)', 'Models'], + colAligns: ['left', 'left', 'right', 'right', 'right', 'right', 'left'], + compactHead: ['Source', 'Month', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + }); + + let hasCodex = false; + for (const row of data) { + const cacheTokens = row.cacheReadTokens + row.cacheCreationTokens; + if (row.source === 'codex') { + hasCodex = true; + } + + table.push([ + formatSourceLabel(row.source), + row.month, + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatCacheValue(row.source, cacheTokens), + formatCurrency(row.costUSD), + formatModelsDisplayMultiline(row.models), + ]); + } + + log(table.toString()); + + if (hasCodex) { + log(`\n${CODEX_CACHE_NOTE}`); + } + + if (totals != null) { + log(`\n${formatCostSummary(totals)}`); + } + + if (table.isCompactMode()) { + log('\nRunning in Compact Mode'); + log('Expand terminal width to see cache metrics and models'); + } + }, +}); diff --git a/apps/omni/src/commands/session.ts b/apps/omni/src/commands/session.ts new file mode 100644 index 00000000..9aa66c03 --- /dev/null +++ b/apps/omni/src/commands/session.ts @@ -0,0 +1,185 @@ +import process from 'node:process'; +import { + formatCurrency, + formatModelsDisplayMultiline, + formatNumber, + ResponsiveTable, +} from '@ccusage/terminal/table'; +import { define } from 'gunshi'; +import { CODEX_CACHE_NOTE } from '../_consts.ts'; +import { + loadCombinedSessionData, + normalizeDateInput, + parseSources, + resolveDateRangeFromDays, +} from '../data-aggregator.ts'; +import { log, logger } from '../logger.ts'; +import { + formatCacheValue, + formatCostSummary, + formatSourceLabel, + formatSourcesTitle, +} from './_shared.ts'; + +function formatActivity(value: string): string { + return value.length >= 10 ? value.slice(0, 10) : value; +} + +export const sessionCommand = define({ + name: 'session', + description: 'Show combined usage report grouped by session', + args: { + json: { + type: 'boolean', + short: 'j', + description: 'Output in JSON format', + default: false, + }, + sources: { + type: 'string', + short: 's', + description: 'Comma-separated list of sources to include', + }, + compact: { + type: 'boolean', + short: 'c', + description: 'Force compact table mode', + default: false, + }, + since: { + type: 'string', + description: 'Start date (YYYY-MM-DD or YYYYMMDD)', + }, + until: { + type: 'string', + description: 'End date (YYYY-MM-DD or YYYYMMDD)', + }, + days: { + type: 'number', + short: 'd', + description: 'Show last N days', + }, + timezone: { + type: 'string', + description: 'Timezone for date grouping', + }, + locale: { + type: 'string', + description: 'Locale for formatting', + }, + offline: { + type: 'boolean', + negatable: true, + description: 'Use cached pricing data', + default: false, + }, + }, + async run(ctx) { + const jsonOutput = Boolean(ctx.values.json); + if (jsonOutput) { + logger.level = 0; + } + + let sources; + let since: string | undefined; + let until: string | undefined; + + try { + sources = parseSources(ctx.values.sources); + since = normalizeDateInput(ctx.values.since); + until = normalizeDateInput(ctx.values.until); + + if (ctx.values.days != null) { + const range = resolveDateRangeFromDays(ctx.values.days, ctx.values.timezone); + since = range.since; + until = range.until; + } + } catch (error) { + logger.error(String(error)); + process.exit(1); + } + + const { data, totals } = await loadCombinedSessionData({ + sources, + since, + until, + timezone: ctx.values.timezone, + locale: ctx.values.locale, + offline: ctx.values.offline, + }); + + if (data.length === 0) { + log(jsonOutput ? JSON.stringify({ sessions: [], totals: null }) : 'No usage data found.'); + return; + } + + if (jsonOutput) { + log( + JSON.stringify( + { + sessions: data, + totals, + }, + null, + 2, + ), + ); + return; + } + + logger.box(`Omni Usage Report - Sessions (${formatSourcesTitle(sources)})`); + + const table: ResponsiveTable = new ResponsiveTable({ + head: [ + 'Source', + 'Session', + 'Last Activity', + 'Input', + 'Output', + 'Cache', + 'Cost (USD)', + 'Models', + ], + colAligns: ['left', 'left', 'left', 'right', 'right', 'right', 'right', 'left'], + compactHead: ['Source', 'Session', 'Input', 'Output', 'Cost (USD)'], + compactColAligns: ['left', 'left', 'right', 'right', 'right'], + compactThreshold: 100, + forceCompact: ctx.values.compact, + style: { head: ['cyan'] }, + }); + + let hasCodex = false; + for (const row of data) { + const cacheTokens = row.cacheReadTokens + row.cacheCreationTokens; + if (row.source === 'codex') { + hasCodex = true; + } + + table.push([ + formatSourceLabel(row.source), + row.displayName, + formatActivity(row.lastTimestamp), + formatNumber(row.inputTokens), + formatNumber(row.outputTokens), + formatCacheValue(row.source, cacheTokens), + formatCurrency(row.costUSD), + formatModelsDisplayMultiline(row.models), + ]); + } + + log(table.toString()); + + if (hasCodex) { + log(`\n${CODEX_CACHE_NOTE}`); + } + + if (totals != null) { + log(`\n${formatCostSummary(totals)}`); + } + + if (table.isCompactMode()) { + log('\nRunning in Compact Mode'); + log('Expand terminal width to see cache metrics and models'); + } + }, +}); diff --git a/apps/omni/src/data-aggregator.ts b/apps/omni/src/data-aggregator.ts new file mode 100644 index 00000000..a836fcc0 --- /dev/null +++ b/apps/omni/src/data-aggregator.ts @@ -0,0 +1,566 @@ +import type { + CombinedTotals, + Source, + SourceTotals, + UnifiedDailyUsage, + UnifiedMonthlyUsage, + UnifiedSessionUsage, +} from './_types.ts'; +import { buildDailyReport as buildCodexDailyReport } from '@ccusage/codex/daily-report'; +import { loadTokenUsageEvents } from '@ccusage/codex/data-loader'; +import { buildMonthlyReport as buildCodexMonthlyReport } from '@ccusage/codex/monthly-report'; +import { CodexPricingSource } from '@ccusage/codex/pricing'; +import { buildSessionReport as buildCodexSessionReport } from '@ccusage/codex/session-report'; +import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; +import { buildDailyReport as buildOpenCodeDailyReport } from '@ccusage/opencode/daily-report'; +import { loadOpenCodeMessages, loadOpenCodeSessions } from '@ccusage/opencode/data-loader'; +import { buildMonthlyReport as buildOpenCodeMonthlyReport } from '@ccusage/opencode/monthly-report'; +import { buildSessionReport as buildOpenCodeSessionReport } from '@ccusage/opencode/session-report'; +import { + loadPiAgentDailyData, + loadPiAgentMonthlyData, + loadPiAgentSessionData, +} from '@ccusage/pi/data-loader'; +import { loadDailyUsageData, loadMonthlyUsageData, loadSessionData } from 'ccusage/data-loader'; +import { SOURCE_ORDER } from './_consts.ts'; +import { + normalizeClaudeDaily, + normalizeClaudeMonthly, + normalizeClaudeSession, + normalizeCodexDaily, + normalizeCodexMonthly, + normalizeCodexSession, + normalizeOpenCodeDaily, + normalizeOpenCodeMonthly, + normalizeOpenCodeSession, + normalizePiDaily, + normalizePiMonthly, + normalizePiSession, +} from './_normalizers/index.ts'; +import { Sources } from './_types.ts'; +import { logger } from './logger.ts'; + +export type CombinedLoadOptions = { + sources?: Source[]; + since?: string; // YYYY-MM-DD + until?: string; // YYYY-MM-DD + timezone?: string; + locale?: string; + offline?: boolean; +}; + +export type CombinedResult = { + data: T[]; + totals: CombinedTotals | null; +}; + +function calculateTotals< + T extends { source: Source; costUSD: number } & { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalTokens: number; + }, +>(entries: T[]): CombinedTotals | null { + if (entries.length === 0) { + return null; + } + + const bySourceMap = new Map(); + + for (const entry of entries) { + const existing = bySourceMap.get(entry.source) ?? { + source: entry.source, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + costUSD: 0, + }; + + existing.inputTokens += entry.inputTokens; + existing.outputTokens += entry.outputTokens; + existing.cacheReadTokens += entry.cacheReadTokens; + existing.cacheCreationTokens += entry.cacheCreationTokens; + existing.totalTokens += entry.totalTokens; + existing.costUSD += entry.costUSD; + + bySourceMap.set(entry.source, existing); + } + + const bySource = SOURCE_ORDER.filter((source) => bySourceMap.has(source)).map( + (source) => bySourceMap.get(source)!, + ); + + const costUSD = bySource.reduce((sum, source) => sum + source.costUSD, 0); + + return { + costUSD, + bySource, + }; +} + +function isSourceEnabled(source: Source, selected?: Source[]): boolean { + if (selected == null || selected.length === 0) { + return true; + } + return selected.includes(source); +} + +function toCompactDate(value?: string): string | undefined { + if (value == null) { + return undefined; + } + return value.replace(/-/g, ''); +} + +export async function loadCombinedDailyData( + options: CombinedLoadOptions = {}, +): Promise> { + const results: UnifiedDailyUsage[] = []; + const selectedSources = options.sources; + const claudeSince = toCompactDate(options.since); + const claudeUntil = toCompactDate(options.until); + + if (isSourceEnabled('claude', selectedSources)) { + try { + const dailyData = await loadDailyUsageData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of dailyData) { + results.push(normalizeClaudeDaily(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude daily usage data.', error); + } + } + + if (isSourceEnabled('codex', selectedSources)) { + try { + const { events, missingDirectories } = await loadTokenUsageEvents(); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } + + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexDailyReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + formatDate: false, + }); + + for (const row of rows) { + results.push(normalizeCodexDaily(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } + } + } catch (error) { + logger.warn('Failed to load Codex daily usage data.', error); + } + } + + if (isSourceEnabled('opencode', selectedSources)) { + try { + const entries = await loadOpenCodeMessages(); + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + const rows = await buildOpenCodeDailyReport(entries, { pricingFetcher: fetcher }); + for (const row of rows) { + results.push(normalizeOpenCodeDaily(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode daily usage data.', error); + } + } + + if (isSourceEnabled('pi', selectedSources)) { + try { + const piData = await loadPiAgentDailyData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiDaily(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi daily usage data.', error); + } + } + + results.sort((a, b) => { + const dateCompare = a.date.localeCompare(b.date); + if (dateCompare !== 0) { + return dateCompare; + } + return SOURCE_ORDER.indexOf(a.source) - SOURCE_ORDER.indexOf(b.source); + }); + + return { + data: results, + totals: calculateTotals(results), + }; +} + +export async function loadCombinedMonthlyData( + options: CombinedLoadOptions = {}, +): Promise> { + const results: UnifiedMonthlyUsage[] = []; + const selectedSources = options.sources; + const claudeSince = toCompactDate(options.since); + const claudeUntil = toCompactDate(options.until); + + if (isSourceEnabled('claude', selectedSources)) { + try { + const monthlyData = await loadMonthlyUsageData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of monthlyData) { + results.push(normalizeClaudeMonthly(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude monthly usage data.', error); + } + } + + if (isSourceEnabled('codex', selectedSources)) { + try { + const { events, missingDirectories } = await loadTokenUsageEvents(); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } + + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexMonthlyReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + formatDate: false, + }); + + for (const row of rows) { + results.push(normalizeCodexMonthly(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } + } + } catch (error) { + logger.warn('Failed to load Codex monthly usage data.', error); + } + } + + if (isSourceEnabled('opencode', selectedSources)) { + try { + const entries = await loadOpenCodeMessages(); + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + const rows = await buildOpenCodeMonthlyReport(entries, { pricingFetcher: fetcher }); + for (const row of rows) { + results.push(normalizeOpenCodeMonthly(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode monthly usage data.', error); + } + } + + if (isSourceEnabled('pi', selectedSources)) { + try { + const piData = await loadPiAgentMonthlyData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiMonthly(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi monthly usage data.', error); + } + } + + results.sort((a, b) => { + const monthCompare = a.month.localeCompare(b.month); + if (monthCompare !== 0) { + return monthCompare; + } + return SOURCE_ORDER.indexOf(a.source) - SOURCE_ORDER.indexOf(b.source); + }); + + return { + data: results, + totals: calculateTotals(results), + }; +} + +export async function loadCombinedSessionData( + options: CombinedLoadOptions = {}, +): Promise> { + const results: UnifiedSessionUsage[] = []; + const selectedSources = options.sources; + const claudeSince = toCompactDate(options.since); + const claudeUntil = toCompactDate(options.until); + + if (isSourceEnabled('claude', selectedSources)) { + try { + const sessionData = await loadSessionData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of sessionData) { + results.push(normalizeClaudeSession(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude session usage data.', error); + } + } + + if (isSourceEnabled('codex', selectedSources)) { + try { + const { events, missingDirectories } = await loadTokenUsageEvents(); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } + + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexSessionReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + }); + + for (const row of rows) { + results.push(normalizeCodexSession(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } + } + } catch (error) { + logger.warn('Failed to load Codex session usage data.', error); + } + } + + if (isSourceEnabled('opencode', selectedSources)) { + try { + const [entries, sessionMetadata] = await Promise.all([ + loadOpenCodeMessages(), + loadOpenCodeSessions(), + ]); + + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + const rows = await buildOpenCodeSessionReport(entries, { + pricingFetcher: fetcher, + sessionMetadata, + }); + for (const row of rows) { + results.push(normalizeOpenCodeSession(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode session usage data.', error); + } + } + + if (isSourceEnabled('pi', selectedSources)) { + try { + const piData = await loadPiAgentSessionData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiSession(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi session usage data.', error); + } + } + + results.sort((a, b) => { + const timeCompare = a.lastTimestamp.localeCompare(b.lastTimestamp); + if (timeCompare !== 0) { + return timeCompare; + } + return SOURCE_ORDER.indexOf(a.source) - SOURCE_ORDER.indexOf(b.source); + }); + + return { + data: results, + totals: calculateTotals(results), + }; +} + +export function parseSources(value?: string): Source[] { + if (value == null || value.trim() === '') { + return [...Sources]; + } + + const normalized = value + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); + + const seen = new Set(); + const sources: Source[] = []; + const invalid: string[] = []; + + for (const item of normalized) { + if (!(Sources as readonly string[]).includes(item)) { + invalid.push(item); + continue; + } + + const source = item as Source; + if (!seen.has(source)) { + seen.add(source); + sources.push(source); + } + } + + if (invalid.length > 0) { + throw new Error(`Unknown sources: ${invalid.join(', ')}`); + } + + return sources; +} + +export function normalizeDateInput(value?: string): string | undefined { + if (value == null) { + return undefined; + } + + const compact = value.replace(/-/g, '').trim(); + if (!/^\d{8}$/.test(compact)) { + throw new Error(`Invalid date format: ${value}. Expected YYYYMMDD or YYYY-MM-DD.`); + } + + return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; +} + +export function resolveDateRangeFromDays( + days?: number, + timezone?: string, +): { since?: string; until?: string } { + if (days == null) { + return {}; + } + + if (!Number.isFinite(days) || days <= 0) { + throw new Error('Days must be a positive number.'); + } + + const tz = timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: tz, + }); + + const now = new Date(); + const until = formatter.format(now); + const start = new Date(now.getTime() - (days - 1) * 24 * 60 * 60 * 1000); + const since = formatter.format(start); + + return { since, until }; +} + +if (import.meta.vitest != null) { + describe('calculateTotals', () => { + it('aggregates per-source totals and overall cost', () => { + const totals = calculateTotals([ + { + source: 'claude', + date: '2025-01-01', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 2, + cacheCreationTokens: 1, + totalTokens: 18, + costUSD: 1, + models: [], + }, + { + source: 'codex', + date: '2025-01-02', + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 5, + cacheCreationTokens: 0, + totalTokens: 30, + costUSD: 2, + models: [], + }, + ]); + + expect(totals?.costUSD).toBe(3); + expect(totals?.bySource).toHaveLength(2); + expect(totals?.bySource[0]?.source).toBe('claude'); + }); + }); + + describe('parseSources', () => { + it('parses a comma-separated list of sources', () => { + const sources = parseSources('claude,codex'); + expect(sources).toEqual(['claude', 'codex']); + }); + + it('throws on unknown sources', () => { + expect(() => parseSources('claude,unknown')).toThrow('Unknown sources'); + }); + }); + + describe('normalizeDateInput', () => { + it('normalizes compact date to YYYY-MM-DD', () => { + expect(normalizeDateInput('20250105')).toBe('2025-01-05'); + }); + + it('keeps dashed date format', () => { + expect(normalizeDateInput('2025-01-05')).toBe('2025-01-05'); + }); + }); +} diff --git a/apps/omni/src/index.ts b/apps/omni/src/index.ts new file mode 100644 index 00000000..c77c0ed5 --- /dev/null +++ b/apps/omni/src/index.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node + +import { run } from './run.ts'; + +// eslint-disable-next-line antfu/no-top-level-await +await run(); diff --git a/apps/omni/src/logger.ts b/apps/omni/src/logger.ts new file mode 100644 index 00000000..ce7384d0 --- /dev/null +++ b/apps/omni/src/logger.ts @@ -0,0 +1,7 @@ +import { createLogger, log as internalLog } from '@ccusage/internal/logger'; + +import { name } from '../package.json'; + +export const logger = createLogger(name); + +export const log = internalLog; diff --git a/apps/omni/src/run.ts b/apps/omni/src/run.ts new file mode 100644 index 00000000..40b1623d --- /dev/null +++ b/apps/omni/src/run.ts @@ -0,0 +1,29 @@ +import process from 'node:process'; +import { cli } from 'gunshi'; +import { description, name, version } from '../package.json'; +import { dailyCommand } from './commands/daily.ts'; +import { monthlyCommand } from './commands/monthly.ts'; +import { sessionCommand } from './commands/session.ts'; + +const subCommands = new Map([ + ['daily', dailyCommand], + ['monthly', monthlyCommand], + ['session', sessionCommand], +]); + +const mainCommand = dailyCommand; + +export async function run(): Promise { + let args = process.argv.slice(2); + if (args[0] === name || args[0] === 'ccusage-omni') { + args = args.slice(1); + } + + await cli(args, mainCommand, { + name, + version, + description, + subCommands, + renderHeader: null, + }); +} diff --git a/apps/omni/tsconfig.json b/apps/omni/tsconfig.json new file mode 100644 index 00000000..67a71ac2 --- /dev/null +++ b/apps/omni/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "moduleDetection": "force", + "module": "Preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["vitest/globals", "vitest/importMeta"], + "allowImportingTsExtensions": true, + "allowJs": false, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "skipLibCheck": true + }, + "exclude": ["dist"] +} diff --git a/apps/omni/tsdown.config.ts b/apps/omni/tsdown.config.ts new file mode 100644 index 00000000..efa0b835 --- /dev/null +++ b/apps/omni/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + clean: true, + dts: false, + shims: true, + platform: 'node', + target: 'node20', + fixedExtension: false, + define: { + 'import.meta.vitest': 'undefined', + }, +}); diff --git a/apps/omni/vitest.config.ts b/apps/omni/vitest.config.ts new file mode 100644 index 00000000..7c5b3f9c --- /dev/null +++ b/apps/omni/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + includeSource: ['src/**/*.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, + define: { + 'import.meta.vitest': 'undefined', + }, +}); diff --git a/apps/opencode/package.json b/apps/opencode/package.json index ec528454..7f219693 100644 --- a/apps/opencode/package.json +++ b/apps/opencode/package.json @@ -18,6 +18,14 @@ "bugs": { "url": "https://github.com/ryoppippi/ccusage/issues" }, + "exports": { + ".": "./src/index.ts", + "./data-loader": "./src/data-loader.ts", + "./daily-report": "./src/daily-report.ts", + "./monthly-report": "./src/monthly-report.ts", + "./session-report": "./src/session-report.ts", + "./package.json": "./package.json" + }, "main": "./dist/index.js", "module": "./dist/index.js", "bin": { @@ -29,6 +37,14 @@ "publishConfig": { "bin": { "ccusage-opencode": "./dist/index.js" + }, + "exports": { + ".": "./dist/index.js", + "./data-loader": "./dist/data-loader.js", + "./daily-report": "./dist/daily-report.js", + "./monthly-report": "./dist/monthly-report.js", + "./session-report": "./dist/session-report.js", + "./package.json": "./package.json" } }, "engines": { diff --git a/apps/opencode/src/commands/daily.ts b/apps/opencode/src/commands/daily.ts index 1ad9b1a8..a3f3e897 100644 --- a/apps/opencode/src/commands/daily.ts +++ b/apps/opencode/src/commands/daily.ts @@ -7,10 +7,9 @@ import { formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; -import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; -import { calculateCostForEntry } from '../cost-utils.ts'; +import { buildDailyReport } from '../daily-report.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; import { logger } from '../logger.ts'; @@ -46,51 +45,7 @@ export const dailyCommand = define({ using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); - const entriesByDate = groupBy(entries, (entry) => entry.timestamp.toISOString().split('T')[0]!); - - const dailyData: Array<{ - date: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; - totalCost: number; - modelsUsed: string[]; - }> = []; - - for (const [date, dayEntries] of Object.entries(entriesByDate)) { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let totalCost = 0; - const modelsSet = new Set(); - - for (const entry of dayEntries) { - inputTokens += entry.usage.inputTokens; - outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; - totalCost += await calculateCostForEntry(entry, fetcher); - modelsSet.add(entry.model); - } - - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - dailyData.push({ - date, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - totalTokens, - totalCost, - modelsUsed: Array.from(modelsSet), - }); - } - - dailyData.sort((a, b) => a.date.localeCompare(b.date)); + const dailyData = await buildDailyReport(entries, { pricingFetcher: fetcher }); const totals = { inputTokens: dailyData.reduce((sum, d) => sum + d.inputTokens, 0), diff --git a/apps/opencode/src/commands/monthly.ts b/apps/opencode/src/commands/monthly.ts index 453795c5..e2448758 100644 --- a/apps/opencode/src/commands/monthly.ts +++ b/apps/opencode/src/commands/monthly.ts @@ -7,12 +7,11 @@ import { formatNumber, ResponsiveTable, } from '@ccusage/terminal/table'; -import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; -import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages } from '../data-loader.ts'; import { logger } from '../logger.ts'; +import { buildMonthlyReport } from '../monthly-report.ts'; const TABLE_COLUMN_COUNT = 8; @@ -46,51 +45,7 @@ export const monthlyCommand = define({ using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); - const entriesByMonth = groupBy(entries, (entry) => entry.timestamp.toISOString().slice(0, 7)); - - const monthlyData: Array<{ - month: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; - totalCost: number; - modelsUsed: string[]; - }> = []; - - for (const [month, monthEntries] of Object.entries(entriesByMonth)) { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let totalCost = 0; - const modelsSet = new Set(); - - for (const entry of monthEntries) { - inputTokens += entry.usage.inputTokens; - outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; - totalCost += await calculateCostForEntry(entry, fetcher); - modelsSet.add(entry.model); - } - - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - monthlyData.push({ - month, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - totalTokens, - totalCost, - modelsUsed: Array.from(modelsSet), - }); - } - - monthlyData.sort((a, b) => a.month.localeCompare(b.month)); + const monthlyData = await buildMonthlyReport(entries, { pricingFetcher: fetcher }); const totals = { inputTokens: monthlyData.reduce((sum, d) => sum + d.inputTokens, 0), diff --git a/apps/opencode/src/commands/session.ts b/apps/opencode/src/commands/session.ts index c36467c0..e05122f4 100644 --- a/apps/opencode/src/commands/session.ts +++ b/apps/opencode/src/commands/session.ts @@ -10,9 +10,9 @@ import { import { groupBy } from 'es-toolkit'; import { define } from 'gunshi'; import pc from 'picocolors'; -import { calculateCostForEntry } from '../cost-utils.ts'; import { loadOpenCodeMessages, loadOpenCodeSessions } from '../data-loader.ts'; import { logger } from '../logger.ts'; +import { buildSessionReport } from '../session-report.ts'; const TABLE_COLUMN_COUNT = 8; @@ -49,66 +49,10 @@ export const sessionCommand = define({ using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); - const entriesBySession = groupBy(entries, (entry) => entry.sessionID); - - type SessionData = { - sessionID: string; - sessionTitle: string; - parentID: string | null; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; - totalCost: number; - modelsUsed: string[]; - lastActivity: Date; - }; - - const sessionData: SessionData[] = []; - - for (const [sessionID, sessionEntries] of Object.entries(entriesBySession)) { - let inputTokens = 0; - let outputTokens = 0; - let cacheCreationTokens = 0; - let cacheReadTokens = 0; - let totalCost = 0; - const modelsSet = new Set(); - let lastActivity = sessionEntries[0]!.timestamp; - - for (const entry of sessionEntries) { - inputTokens += entry.usage.inputTokens; - outputTokens += entry.usage.outputTokens; - cacheCreationTokens += entry.usage.cacheCreationInputTokens; - cacheReadTokens += entry.usage.cacheReadInputTokens; - totalCost += await calculateCostForEntry(entry, fetcher); - modelsSet.add(entry.model); - - if (entry.timestamp > lastActivity) { - lastActivity = entry.timestamp; - } - } - - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - const metadata = sessionMetadataMap.get(sessionID); - - sessionData.push({ - sessionID, - sessionTitle: metadata?.title ?? sessionID, - parentID: metadata?.parentID ?? null, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - totalTokens, - totalCost, - modelsUsed: Array.from(modelsSet), - lastActivity, - }); - } - - sessionData.sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime()); + const sessionData = await buildSessionReport(entries, { + pricingFetcher: fetcher, + sessionMetadata: sessionMetadataMap, + }); const totals = { inputTokens: sessionData.reduce((sum, s) => sum + s.inputTokens, 0), diff --git a/apps/opencode/src/daily-report.ts b/apps/opencode/src/daily-report.ts new file mode 100644 index 00000000..606e92c8 --- /dev/null +++ b/apps/opencode/src/daily-report.ts @@ -0,0 +1,63 @@ +import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; +import type { LoadedUsageEntry } from './data-loader.ts'; +import { groupBy } from 'es-toolkit'; +import { calculateCostForEntry } from './cost-utils.ts'; + +export type DailyReportRow = { + date: string; // YYYY-MM-DD + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + modelsUsed: string[]; +}; + +export type DailyReportOptions = { + pricingFetcher: LiteLLMPricingFetcher; +}; + +export async function buildDailyReport( + entries: LoadedUsageEntry[], + options: DailyReportOptions, +): Promise { + const entriesByDate = groupBy(entries, (entry) => entry.timestamp.toISOString().split('T')[0]!); + + const dailyData: DailyReportRow[] = []; + + for (const [date, dayEntries] of Object.entries(entriesByDate)) { + let inputTokens = 0; + let outputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + let totalCost = 0; + const modelsSet = new Set(); + + for (const entry of dayEntries) { + inputTokens += entry.usage.inputTokens; + outputTokens += entry.usage.outputTokens; + cacheCreationTokens += entry.usage.cacheCreationInputTokens; + cacheReadTokens += entry.usage.cacheReadInputTokens; + totalCost += await calculateCostForEntry(entry, options.pricingFetcher); + modelsSet.add(entry.model); + } + + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + + dailyData.push({ + date, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + totalCost, + modelsUsed: Array.from(modelsSet), + }); + } + + dailyData.sort((a, b) => a.date.localeCompare(b.date)); + + return dailyData; +} diff --git a/apps/opencode/src/monthly-report.ts b/apps/opencode/src/monthly-report.ts new file mode 100644 index 00000000..008a1b47 --- /dev/null +++ b/apps/opencode/src/monthly-report.ts @@ -0,0 +1,63 @@ +import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; +import type { LoadedUsageEntry } from './data-loader.ts'; +import { groupBy } from 'es-toolkit'; +import { calculateCostForEntry } from './cost-utils.ts'; + +export type MonthlyReportRow = { + month: string; // YYYY-MM + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + modelsUsed: string[]; +}; + +export type MonthlyReportOptions = { + pricingFetcher: LiteLLMPricingFetcher; +}; + +export async function buildMonthlyReport( + entries: LoadedUsageEntry[], + options: MonthlyReportOptions, +): Promise { + const entriesByMonth = groupBy(entries, (entry) => entry.timestamp.toISOString().slice(0, 7)); + + const monthlyData: MonthlyReportRow[] = []; + + for (const [month, monthEntries] of Object.entries(entriesByMonth)) { + let inputTokens = 0; + let outputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + let totalCost = 0; + const modelsSet = new Set(); + + for (const entry of monthEntries) { + inputTokens += entry.usage.inputTokens; + outputTokens += entry.usage.outputTokens; + cacheCreationTokens += entry.usage.cacheCreationInputTokens; + cacheReadTokens += entry.usage.cacheReadInputTokens; + totalCost += await calculateCostForEntry(entry, options.pricingFetcher); + modelsSet.add(entry.model); + } + + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + + monthlyData.push({ + month, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + totalCost, + modelsUsed: Array.from(modelsSet), + }); + } + + monthlyData.sort((a, b) => a.month.localeCompare(b.month)); + + return monthlyData; +} diff --git a/apps/opencode/src/session-report.ts b/apps/opencode/src/session-report.ts new file mode 100644 index 00000000..a6f64654 --- /dev/null +++ b/apps/opencode/src/session-report.ts @@ -0,0 +1,77 @@ +import type { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; +import type { LoadedSessionMetadata, LoadedUsageEntry } from './data-loader.ts'; +import { groupBy } from 'es-toolkit'; +import { calculateCostForEntry } from './cost-utils.ts'; + +export type SessionReportRow = { + sessionID: string; + sessionTitle: string; + parentID: string | null; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + totalCost: number; + modelsUsed: string[]; + lastActivity: string; // ISO timestamp +}; + +export type SessionReportOptions = { + pricingFetcher: LiteLLMPricingFetcher; + sessionMetadata?: Map; +}; + +export async function buildSessionReport( + entries: LoadedUsageEntry[], + options: SessionReportOptions, +): Promise { + const entriesBySession = groupBy(entries, (entry) => entry.sessionID); + const sessionMetadata = options.sessionMetadata ?? new Map(); + + const sessionData: SessionReportRow[] = []; + + for (const [sessionID, sessionEntries] of Object.entries(entriesBySession)) { + let inputTokens = 0; + let outputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + let totalCost = 0; + const modelsSet = new Set(); + let lastActivity = sessionEntries[0]!.timestamp; + + for (const entry of sessionEntries) { + inputTokens += entry.usage.inputTokens; + outputTokens += entry.usage.outputTokens; + cacheCreationTokens += entry.usage.cacheCreationInputTokens; + cacheReadTokens += entry.usage.cacheReadInputTokens; + totalCost += await calculateCostForEntry(entry, options.pricingFetcher); + modelsSet.add(entry.model); + + if (entry.timestamp > lastActivity) { + lastActivity = entry.timestamp; + } + } + + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const metadata = sessionMetadata.get(sessionID); + + sessionData.push({ + sessionID, + sessionTitle: metadata?.title ?? sessionID, + parentID: metadata?.parentID ?? null, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + totalCost, + modelsUsed: Array.from(modelsSet), + lastActivity: lastActivity.toISOString(), + }); + } + + sessionData.sort((a, b) => a.lastActivity.localeCompare(b.lastActivity)); + + return sessionData; +} diff --git a/apps/opencode/tsdown.config.ts b/apps/opencode/tsdown.config.ts index 2ba2ac86..055c25b4 100644 --- a/apps/opencode/tsdown.config.ts +++ b/apps/opencode/tsdown.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: [ + 'src/index.ts', + 'src/data-loader.ts', + 'src/daily-report.ts', + 'src/monthly-report.ts', + 'src/session-report.ts', + ], format: ['esm'], clean: true, dts: false, diff --git a/apps/pi/package.json b/apps/pi/package.json index 0e1d0d71..14a9c8d3 100644 --- a/apps/pi/package.json +++ b/apps/pi/package.json @@ -20,6 +20,7 @@ }, "exports": { ".": "./src/index.ts", + "./data-loader": "./src/data-loader.ts", "./package.json": "./package.json" }, "main": "./dist/index.js", @@ -38,6 +39,7 @@ }, "exports": { ".": "./dist/index.js", + "./data-loader": "./dist/data-loader.js", "./package.json": "./package.json" } }, diff --git a/apps/pi/tsdown.config.ts b/apps/pi/tsdown.config.ts index 745d9ae1..154670bc 100644 --- a/apps/pi/tsdown.config.ts +++ b/apps/pi/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/data-loader.ts'], outDir: 'dist', format: 'esm', clean: true, diff --git a/eslint.config.js b/eslint.config.js index d1d48dfb..81281dbb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,5 +3,5 @@ import { ryoppippi } from '@ryoppippi/eslint-config'; export default ryoppippi({ type: 'lib', stylistic: false, - ignores: ['apps', 'packages', 'docs', '.claude/settings.local.json'], + ignores: ['apps', 'packages', 'docs', '.claude/settings.local.json', 'OMNI_PLAN.md'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33a20bd..dbaa9860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -521,6 +521,75 @@ importers: specifier: catalog:testing version: 4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1) + apps/omni: + devDependencies: + '@ccusage/codex': + specifier: workspace:* + version: link:../codex + '@ccusage/internal': + specifier: workspace:* + version: link:../../packages/internal + '@ccusage/opencode': + specifier: workspace:* + version: link:../opencode + '@ccusage/pi': + specifier: workspace:* + version: link:../pi + '@ccusage/terminal': + specifier: workspace:* + version: link:../../packages/terminal + '@praha/byethrow': + specifier: catalog:runtime + version: 0.6.3 + '@ryoppippi/eslint-config': + specifier: catalog:lint + version: 0.4.0(@vue/compiler-sfc@3.5.21)(eslint-plugin-format@1.0.2(eslint@9.35.0(jiti@2.6.1)))(eslint@9.35.0(jiti@2.6.1))(typescript@5.9.2)(vitest@4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1)) + '@typescript/native-preview': + specifier: catalog:types + version: 7.0.0-dev.20260107.1 + bun: + specifier: runtime:^1.3.2 + version: runtime:1.3.6 + ccusage: + specifier: workspace:* + version: link:../ccusage + clean-pkg-json: + specifier: catalog:release + version: 1.3.0 + es-toolkit: + specifier: catalog:runtime + version: 1.39.10 + eslint: + specifier: catalog:lint + version: 9.35.0(jiti@2.6.1) + fast-sort: + specifier: catalog:runtime + version: 3.4.1 + fs-fixture: + specifier: catalog:testing + version: 2.8.1 + gunshi: + specifier: catalog:runtime + version: 0.26.3 + node: + specifier: runtime:^24.11.0 + version: runtime:24.13.0 + picocolors: + specifier: catalog:runtime + version: 1.1.1 + tsdown: + specifier: catalog:build + version: 0.16.6(@typescript/native-preview@7.0.0-dev.20260107.1)(publint@0.3.12)(synckit@0.11.11)(typescript@5.9.2)(unplugin-unused@0.5.3) + type-fest: + specifier: catalog:runtime + version: 4.41.0 + valibot: + specifier: catalog:runtime + version: 1.1.0(typescript@5.9.2) + vitest: + specifier: catalog:testing + version: 4.0.15(@types/node@24.5.1)(happy-dom@16.8.1)(jiti@2.6.1)(yaml@2.8.1) + apps/opencode: devDependencies: '@ccusage/internal': @@ -2777,6 +2846,85 @@ packages: version: 1.3.5 hasBin: true + bun@runtime:1.3.6: + resolution: + type: variations + variants: + - resolution: + archive: zip + bin: bun + integrity: sha256-KvHshDd1mrBbOw6kIf6eIubHBctMsHUcMmmCZC2s6Po= + prefix: bun-darwin-aarch64 + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-darwin-aarch64.zip + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: zip + bin: bun + integrity: sha256-g++EwqnSXf72ugsxvj2KCZUu8xHHH+ykSIpijpbCZwY= + prefix: bun-darwin-x64 + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-darwin-x64.zip + targets: + - cpu: x64 + os: darwin + - resolution: + archive: zip + bin: bun + integrity: sha256-Ia9dTCdtxKCju9OJOoOZT6nekWtoB/ivmv9D1hk+V50= + prefix: bun-linux-aarch64-musl + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-linux-aarch64-musl.zip + targets: + - cpu: arm64 + os: linux + libc: musl + - resolution: + archive: zip + bin: bun + integrity: sha256-Wv0Ss2a6LYKXJFzCnAOUFjNN2HIVLB2wLlyKqMZulrE= + prefix: bun-linux-aarch64 + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-linux-aarch64.zip + targets: + - cpu: arm64 + os: linux + - resolution: + archive: zip + bin: bun + integrity: sha256-sTBh9+LvWJb/+V2EkhOpYo6diQNst6I4N8bbuohE9jY= + prefix: bun-linux-x64-musl + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-linux-x64-musl.zip + targets: + - cpu: x64 + os: linux + libc: musl + - resolution: + archive: zip + bin: bun + integrity: sha256-m6mNITRVDWaQh1sjpPXEjnS3yyZ+jMG49SYFkhxsEe8= + prefix: bun-linux-x64 + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-linux-x64.zip + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: bun.exe + integrity: sha256-c1byD2RtcEe+6et6PVNEAZvtzneL+rPMzV3R7i32F38= + prefix: bun-windows-x64 + type: binary + url: https://github.com/oven-sh/bun/releases/download/bun-v1.3.6/bun-windows-x64.zip + targets: + - cpu: x64 + os: win32 + version: 1.3.6 + hasBin: true + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -4413,6 +4561,96 @@ packages: version: 24.12.0 hasBin: true + node@runtime:24.13.0: + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-rCHprwik1UsFfYAMA7yVMilGlSuNqoEcramL+2aozo8= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-aix-ppc64.tar.gz + targets: + - cpu: ppc64 + os: aix + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-1ZWWHlY/yuBX1KD7mS8XWlTZf8xKFNwtR02S3e6jufg= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-bwPBtI3b4bEppvgDi+COCJnwXxcYW00+Q1AYCrZpp/M= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-darwin-x64.tar.gz + targets: + - cpu: x64 + os: darwin + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-D21AuUxqLra0wkD/yLn9Otp6sETBd91BPAbh75pj8IE= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-linux-arm64.tar.gz + targets: + - cpu: arm64 + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-GAEZMLGCocW0nSMmGR/bpYJwvfe0W4x9+FXvMZMbFIo= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-linux-ppc64le.tar.gz + targets: + - cpu: ppc64le + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-V0RhC2JPLoKuHKJ52OznuMpGZDcjlTPS0DNWUwO8HTk= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-linux-s390x.tar.gz + targets: + - cpu: s390x + os: linux + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-YiOq0agfnR57aCxZ0S4t4jP3tMN0dc1A0cicQrc3/6g= + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-linux-x64.tar.gz + targets: + - cpu: x64 + os: linux + - resolution: + archive: zip + bin: node.exe + integrity: sha256-krn5sMDBI+EeSvxTXw7BnNmHRl7qUGQnVTpJlxNkFYo= + prefix: node-v24.13.0-win-arm64 + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-win-arm64.zip + targets: + - cpu: arm64 + os: win32 + - resolution: + archive: zip + bin: node.exe + integrity: sha256-yidCaVvo3kQCfXGz9TpL2zYAm5VXX+Gub38LXOCRy4g= + prefix: node-v24.13.0-win-x64 + type: binary + url: https://nodejs.org/download/release/v24.13.0/node-v24.13.0-win-x64.zip + targets: + - cpu: x64 + os: win32 + version: 24.13.0 + hasBin: true + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -7394,6 +7632,8 @@ snapshots: bun@runtime:1.3.5: {} + bun@runtime:1.3.6: {} + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -9290,6 +9530,8 @@ snapshots: node@runtime:24.12.0: {} + node@runtime:24.13.0: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 From 15cf11fe79fd0dc8980ebf9818abcfe7f36567c0 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 14:31:27 -0700 Subject: [PATCH 02/12] fix(ccusage): speed up daily loader filtering --- apps/ccusage/src/data-loader.ts | 55 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 8e22aae2..09959a10 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -765,8 +765,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise(); + const processedEntries = new Map(); + const since = options?.since?.replace(/-/g, ''); + const until = options?.until?.replace(/-/g, ''); + const hasDateFilter = since != null || until != null; // Collect all valid data entries first const allEntries: { @@ -799,24 +801,51 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise until) { + return; + } } - // Mark this combination as processed - markAsProcessed(uniqueHash, processedHashes); + const uniqueHash = createUniqueHash(data); + const timestampMs = new Date(data.timestamp).getTime(); + if (uniqueHash != null) { + const existing = processedEntries.get(uniqueHash); + if (existing != null) { + // Keep the oldest entry for this message+request combination + if (!Number.isNaN(timestampMs) && timestampMs < existing.timestamp) { + const cost = + fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); + allEntries[existing.index] = { + data, + date, + cost, + model: data.message.model, + project, + }; + processedEntries.set(uniqueHash, { timestamp: timestampMs, index: existing.index }); + } + return; + } + } - // Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format - const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE); // If fetcher is available, calculate cost based on mode and tokens // If fetcher is null, use pre-calculated costUSD or default to 0 const cost = fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); - allEntries.push({ data, date, cost, model: data.message.model, project }); + const index = allEntries.push({ data, date, cost, model: data.message.model, project }) - 1; + if (uniqueHash != null && !Number.isNaN(timestampMs)) { + processedEntries.set(uniqueHash, { timestamp: timestampMs, index }); + } } catch { // Skip invalid JSON lines } From 609a2abe4dd38237d818d79592d229407e4a4152 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 16:31:59 -0700 Subject: [PATCH 03/12] fix(omni): filter opencode loads by date --- apps/omni/src/data-aggregator.ts | 21 ++++++++++---- apps/opencode/src/data-loader.ts | 48 +++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/apps/omni/src/data-aggregator.ts b/apps/omni/src/data-aggregator.ts index a836fcc0..f217ce49 100644 --- a/apps/omni/src/data-aggregator.ts +++ b/apps/omni/src/data-aggregator.ts @@ -176,9 +176,12 @@ export async function loadCombinedDailyData( if (isSourceEnabled('opencode', selectedSources)) { try { - const entries = await loadOpenCodeMessages(); + const entries = await loadOpenCodeMessages({ + since: options.since, + until: options.until, + }); if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); const rows = await buildOpenCodeDailyReport(entries, { pricingFetcher: fetcher }); for (const row of rows) { results.push(normalizeOpenCodeDaily(row)); @@ -280,9 +283,12 @@ export async function loadCombinedMonthlyData( if (isSourceEnabled('opencode', selectedSources)) { try { - const entries = await loadOpenCodeMessages(); + const entries = await loadOpenCodeMessages({ + since: options.since, + until: options.until, + }); if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); const rows = await buildOpenCodeMonthlyReport(entries, { pricingFetcher: fetcher }); for (const row of rows) { results.push(normalizeOpenCodeMonthly(row)); @@ -384,12 +390,15 @@ export async function loadCombinedSessionData( if (isSourceEnabled('opencode', selectedSources)) { try { const [entries, sessionMetadata] = await Promise.all([ - loadOpenCodeMessages(), + loadOpenCodeMessages({ + since: options.since, + until: options.until, + }), loadOpenCodeSessions(), ]); if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: false, logger }); + using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); const rows = await buildOpenCodeSessionReport(entries, { pricingFetcher: fetcher, sessionMetadata, diff --git a/apps/opencode/src/data-loader.ts b/apps/opencode/src/data-loader.ts index 1f3968ad..9cde1e10 100644 --- a/apps/opencode/src/data-loader.ts +++ b/apps/opencode/src/data-loader.ts @@ -122,6 +122,39 @@ export type LoadedSessionMetadata = { directory: string; }; +export type OpenCodeMessageLoadOptions = { + since?: string; // YYYY-MM-DD or YYYYMMDD + until?: string; // YYYY-MM-DD or YYYYMMDD +}; + +function normalizeDateInput(value?: string): string | undefined { + if (value == null) { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed === '') { + return undefined; + } + + const compact = trimmed.replace(/-/g, ''); + return /^\d{8}$/.test(compact) ? compact : undefined; +} + +function getDateKeyFromTimestamp(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 10).replace(/-/g, ''); +} + +function isWithinRange(dateKey: string, since?: string, until?: string): boolean { + if (since != null && dateKey < since) { + return false; + } + if (until != null && dateKey > until) { + return false; + } + return true; +} + /** * Get OpenCode data directory * @returns Path to OpenCode data directory, or null if not found @@ -251,7 +284,9 @@ export async function loadOpenCodeSessions(): Promise { +export async function loadOpenCodeMessages( + options: OpenCodeMessageLoadOptions = {}, +): Promise { const openCodePath = getOpenCodePath(); if (openCodePath == null) { return []; @@ -274,6 +309,9 @@ export async function loadOpenCodeMessages(): Promise { }); const entries: LoadedUsageEntry[] = []; + const since = normalizeDateInput(options.since); + const until = normalizeDateInput(options.until); + const hasDateFilter = since != null || until != null; const dedupeSet = new Set(); for (const filePath of messageFiles) { @@ -283,6 +321,14 @@ export async function loadOpenCodeMessages(): Promise { continue; } + const createdMs = message.time.created ?? Date.now(); + if (hasDateFilter) { + const dateKey = getDateKeyFromTimestamp(createdMs); + if (!isWithinRange(dateKey, since, until)) { + continue; + } + } + // Skip messages with no tokens if (message.tokens == null || (message.tokens.input === 0 && message.tokens.output === 0)) { continue; From b33f039ff05d83b3b6a324a352113682b483d15d Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 16:33:58 -0700 Subject: [PATCH 04/12] fix(opencode): prefilter message files by date --- apps/opencode/src/data-loader.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/opencode/src/data-loader.ts b/apps/opencode/src/data-loader.ts index 9cde1e10..c9b0c108 100644 --- a/apps/opencode/src/data-loader.ts +++ b/apps/opencode/src/data-loader.ts @@ -8,7 +8,7 @@ * @module data-loader */ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { isDirectorySync } from 'path-type'; @@ -315,6 +315,18 @@ export async function loadOpenCodeMessages( const dedupeSet = new Set(); for (const filePath of messageFiles) { + if (hasDateFilter) { + try { + const fileStat = await stat(filePath); + const fileDateKey = getDateKeyFromTimestamp(fileStat.mtimeMs); + if (!isWithinRange(fileDateKey, since, until)) { + continue; + } + } catch { + // Fall back to reading file contents when stat fails. + } + } + const message = await loadOpenCodeMessage(filePath); if (message == null) { From cd7ec63fefdefb5bac8d3b3cda53a13ff3aa3088 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 16:51:07 -0700 Subject: [PATCH 05/12] perf(opencode): avoid scanning old session dirs --- apps/opencode/src/data-loader.ts | 41 +++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/opencode/src/data-loader.ts b/apps/opencode/src/data-loader.ts index c9b0c108..d974f44e 100644 --- a/apps/opencode/src/data-loader.ts +++ b/apps/opencode/src/data-loader.ts @@ -8,7 +8,7 @@ * @module data-loader */ -import { readFile, stat } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { isDirectorySync } from 'path-type'; @@ -302,11 +302,7 @@ export async function loadOpenCodeMessages( return []; } - // Find all message JSON files - const messageFiles = await glob('**/*.json', { - cwd: messagesDir, - absolute: true, - }); + let messageFiles: string[] = []; const entries: LoadedUsageEntry[] = []; const since = normalizeDateInput(options.since); @@ -314,6 +310,39 @@ export async function loadOpenCodeMessages( const hasDateFilter = since != null || until != null; const dedupeSet = new Set(); + if (hasDateFilter) { + const sessionEntries = await readdir(messagesDir, { withFileTypes: true }).catch(() => []); + const sessionDirs = sessionEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(messagesDir, entry.name)); + + for (const sessionDir of sessionDirs) { + if (since != null) { + try { + const dirStat = await stat(sessionDir); + const dirDateKey = getDateKeyFromTimestamp(dirStat.mtimeMs); + if (dirDateKey < since) { + continue; + } + } catch { + // Continue to scan the session directory when stat fails. + } + } + + const sessionFiles = await glob('**/*.json', { + cwd: sessionDir, + absolute: true, + }).catch(() => []); + messageFiles.push(...sessionFiles); + } + } else { + // Find all message JSON files + messageFiles = await glob('**/*.json', { + cwd: messagesDir, + absolute: true, + }); + } + for (const filePath of messageFiles) { if (hasDateFilter) { try { From 07489b8ea9dcfe594d2ad6f3096c4948921d6c0d Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Fri, 16 Jan 2026 17:18:36 -0700 Subject: [PATCH 06/12] perf: speed up date-filtered loaders --- apps/ccusage/src/data-loader.ts | 25 ++++++++++++++++++++-- apps/codex/src/data-loader.ts | 36 ++++++++++++++++++++++++++++++++ apps/omni/src/data-aggregator.ts | 15 ++++++++++--- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 09959a10..c1f70cd8 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -13,7 +13,7 @@ import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts'; import { Buffer } from 'node:buffer'; import { createReadStream, createWriteStream } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; @@ -716,6 +716,27 @@ export async function globUsageFiles(claudePaths: string[]): Promise { + if (since == null || since.trim() === '') { + return files; + } + + const sinceKey = since.replace(/-/g, ''); + return ( + await Promise.all( + files.map(async (file) => { + try { + const fileStat = await stat(file); + const dateKey = new Date(fileStat.mtimeMs).toISOString().slice(0, 10).replace(/-/g, ''); + return dateKey >= sinceKey ? file : null; + } catch { + return file; + } + }), + ) + ).filter((file): file is string => file != null); +} + /** * Date range filter for limiting usage data by date */ @@ -765,7 +786,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise { + const normalizeDateInput = (value?: string): string | undefined => { + if (value == null) { + return undefined; + } + const trimmed = value.trim(); + if (trimmed === '') { + return undefined; + } + const compact = trimmed.replace(/-/g, ''); + return /^\d{8}$/.test(compact) ? compact : undefined; + }; + + const since = normalizeDateInput(options.since); + const until = normalizeDateInput(options.until); + const providedDirs = options.sessionDirs != null && options.sessionDirs.length > 0 ? options.sessionDirs.map((dir) => path.resolve(dir)) @@ -222,6 +239,18 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise until) { + continue; + } const info = tokenPayloadResult.output.info; const lastUsage = normalizeRawUsage(info?.last_token_usage); diff --git a/apps/omni/src/data-aggregator.ts b/apps/omni/src/data-aggregator.ts index f217ce49..e434f4d4 100644 --- a/apps/omni/src/data-aggregator.ts +++ b/apps/omni/src/data-aggregator.ts @@ -145,7 +145,10 @@ export async function loadCombinedDailyData( if (isSourceEnabled('codex', selectedSources)) { try { - const { events, missingDirectories } = await loadTokenUsageEvents(); + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + }); for (const missing of missingDirectories) { logger.debug(`Codex session directory not found: ${missing}`); } @@ -252,7 +255,10 @@ export async function loadCombinedMonthlyData( if (isSourceEnabled('codex', selectedSources)) { try { - const { events, missingDirectories } = await loadTokenUsageEvents(); + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + }); for (const missing of missingDirectories) { logger.debug(`Codex session directory not found: ${missing}`); } @@ -359,7 +365,10 @@ export async function loadCombinedSessionData( if (isSourceEnabled('codex', selectedSources)) { try { - const { events, missingDirectories } = await loadTokenUsageEvents(); + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + }); for (const missing of missingDirectories) { logger.debug(`Codex session directory not found: ${missing}`); } From fc7bc6af5554e559e2cb5e14f11fc356f1909197 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 00:17:12 -0700 Subject: [PATCH 07/12] fix: improve omni output and pricing fetch --- apps/omni/src/data-aggregator.ts | 489 ++++++++++++++++++------------- packages/internal/src/pricing.ts | 20 +- packages/terminal/src/table.ts | 20 +- 3 files changed, 315 insertions(+), 214 deletions(-) diff --git a/apps/omni/src/data-aggregator.ts b/apps/omni/src/data-aggregator.ts index e434f4d4..b82d93de 100644 --- a/apps/omni/src/data-aggregator.ts +++ b/apps/omni/src/data-aggregator.ts @@ -123,95 +123,118 @@ export async function loadCombinedDailyData( const selectedSources = options.sources; const claudeSince = toCompactDate(options.since); const claudeUntil = toCompactDate(options.until); + const tasks: Promise[] = []; if (isSourceEnabled('claude', selectedSources)) { - try { - const dailyData = await loadDailyUsageData({ - since: claudeSince, - until: claudeUntil, - timezone: options.timezone, - locale: options.locale, - order: 'asc', - offline: options.offline, - }); - - for (const entry of dailyData) { - results.push(normalizeClaudeDaily(entry)); - } - } catch (error) { - logger.warn('Failed to load Claude daily usage data.', error); - } + tasks.push( + (async () => { + try { + const dailyData = await loadDailyUsageData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of dailyData) { + results.push(normalizeClaudeDaily(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude daily usage data.', error); + } + })(), + ); } if (isSourceEnabled('codex', selectedSources)) { - try { - const { events, missingDirectories } = await loadTokenUsageEvents({ - since: options.since, - until: options.until, - }); - for (const missing of missingDirectories) { - logger.debug(`Codex session directory not found: ${missing}`); - } - - if (events.length > 0) { - const pricingSource = new CodexPricingSource({ offline: options.offline }); + tasks.push( + (async () => { try { - const rows = await buildCodexDailyReport(events, { - pricingSource, - timezone: options.timezone, - locale: options.locale, + const { events, missingDirectories } = await loadTokenUsageEvents({ since: options.since, until: options.until, - formatDate: false, }); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } - for (const row of rows) { - results.push(normalizeCodexDaily(row)); + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexDailyReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + formatDate: false, + }); + + for (const row of rows) { + results.push(normalizeCodexDaily(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } } - } finally { - pricingSource[Symbol.dispose](); + } catch (error) { + logger.warn('Failed to load Codex daily usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load Codex daily usage data.', error); - } + })(), + ); } if (isSourceEnabled('opencode', selectedSources)) { - try { - const entries = await loadOpenCodeMessages({ - since: options.since, - until: options.until, - }); - if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); - const rows = await buildOpenCodeDailyReport(entries, { pricingFetcher: fetcher }); - for (const row of rows) { - results.push(normalizeOpenCodeDaily(row)); + tasks.push( + (async () => { + try { + const entries = await loadOpenCodeMessages({ + since: options.since, + until: options.until, + }); + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ + offline: options.offline === true, + offlineLoader: options.offline === true ? async () => ({}) : undefined, + logger, + }); + const rows = await buildOpenCodeDailyReport(entries, { pricingFetcher: fetcher }); + for (const row of rows) { + results.push(normalizeOpenCodeDaily(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode daily usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load OpenCode daily usage data.', error); - } + })(), + ); } if (isSourceEnabled('pi', selectedSources)) { - try { - const piData = await loadPiAgentDailyData({ - since: options.since, - until: options.until, - timezone: options.timezone, - order: 'asc', - }); - - for (const entry of piData) { - results.push(normalizePiDaily(entry)); - } - } catch (error) { - logger.warn('Failed to load Pi daily usage data.', error); - } + tasks.push( + (async () => { + try { + const piData = await loadPiAgentDailyData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiDaily(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi daily usage data.', error); + } + })(), + ); } + await Promise.all(tasks); + results.sort((a, b) => { const dateCompare = a.date.localeCompare(b.date); if (dateCompare !== 0) { @@ -233,95 +256,118 @@ export async function loadCombinedMonthlyData( const selectedSources = options.sources; const claudeSince = toCompactDate(options.since); const claudeUntil = toCompactDate(options.until); + const tasks: Promise[] = []; if (isSourceEnabled('claude', selectedSources)) { - try { - const monthlyData = await loadMonthlyUsageData({ - since: claudeSince, - until: claudeUntil, - timezone: options.timezone, - locale: options.locale, - order: 'asc', - offline: options.offline, - }); - - for (const entry of monthlyData) { - results.push(normalizeClaudeMonthly(entry)); - } - } catch (error) { - logger.warn('Failed to load Claude monthly usage data.', error); - } + tasks.push( + (async () => { + try { + const monthlyData = await loadMonthlyUsageData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of monthlyData) { + results.push(normalizeClaudeMonthly(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude monthly usage data.', error); + } + })(), + ); } if (isSourceEnabled('codex', selectedSources)) { - try { - const { events, missingDirectories } = await loadTokenUsageEvents({ - since: options.since, - until: options.until, - }); - for (const missing of missingDirectories) { - logger.debug(`Codex session directory not found: ${missing}`); - } - - if (events.length > 0) { - const pricingSource = new CodexPricingSource({ offline: options.offline }); + tasks.push( + (async () => { try { - const rows = await buildCodexMonthlyReport(events, { - pricingSource, - timezone: options.timezone, - locale: options.locale, + const { events, missingDirectories } = await loadTokenUsageEvents({ since: options.since, until: options.until, - formatDate: false, }); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } - for (const row of rows) { - results.push(normalizeCodexMonthly(row)); + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexMonthlyReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + formatDate: false, + }); + + for (const row of rows) { + results.push(normalizeCodexMonthly(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } } - } finally { - pricingSource[Symbol.dispose](); + } catch (error) { + logger.warn('Failed to load Codex monthly usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load Codex monthly usage data.', error); - } + })(), + ); } if (isSourceEnabled('opencode', selectedSources)) { - try { - const entries = await loadOpenCodeMessages({ - since: options.since, - until: options.until, - }); - if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); - const rows = await buildOpenCodeMonthlyReport(entries, { pricingFetcher: fetcher }); - for (const row of rows) { - results.push(normalizeOpenCodeMonthly(row)); + tasks.push( + (async () => { + try { + const entries = await loadOpenCodeMessages({ + since: options.since, + until: options.until, + }); + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ + offline: options.offline === true, + offlineLoader: options.offline === true ? async () => ({}) : undefined, + logger, + }); + const rows = await buildOpenCodeMonthlyReport(entries, { pricingFetcher: fetcher }); + for (const row of rows) { + results.push(normalizeOpenCodeMonthly(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode monthly usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load OpenCode monthly usage data.', error); - } + })(), + ); } if (isSourceEnabled('pi', selectedSources)) { - try { - const piData = await loadPiAgentMonthlyData({ - since: options.since, - until: options.until, - timezone: options.timezone, - order: 'asc', - }); - - for (const entry of piData) { - results.push(normalizePiMonthly(entry)); - } - } catch (error) { - logger.warn('Failed to load Pi monthly usage data.', error); - } + tasks.push( + (async () => { + try { + const piData = await loadPiAgentMonthlyData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiMonthly(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi monthly usage data.', error); + } + })(), + ); } + await Promise.all(tasks); + results.sort((a, b) => { const monthCompare = a.month.localeCompare(b.month); if (monthCompare !== 0) { @@ -343,101 +389,124 @@ export async function loadCombinedSessionData( const selectedSources = options.sources; const claudeSince = toCompactDate(options.since); const claudeUntil = toCompactDate(options.until); + const tasks: Promise[] = []; if (isSourceEnabled('claude', selectedSources)) { - try { - const sessionData = await loadSessionData({ - since: claudeSince, - until: claudeUntil, - timezone: options.timezone, - locale: options.locale, - order: 'asc', - offline: options.offline, - }); - - for (const entry of sessionData) { - results.push(normalizeClaudeSession(entry)); - } - } catch (error) { - logger.warn('Failed to load Claude session usage data.', error); - } + tasks.push( + (async () => { + try { + const sessionData = await loadSessionData({ + since: claudeSince, + until: claudeUntil, + timezone: options.timezone, + locale: options.locale, + order: 'asc', + offline: options.offline, + }); + + for (const entry of sessionData) { + results.push(normalizeClaudeSession(entry)); + } + } catch (error) { + logger.warn('Failed to load Claude session usage data.', error); + } + })(), + ); } if (isSourceEnabled('codex', selectedSources)) { - try { - const { events, missingDirectories } = await loadTokenUsageEvents({ - since: options.since, - until: options.until, - }); - for (const missing of missingDirectories) { - logger.debug(`Codex session directory not found: ${missing}`); - } - - if (events.length > 0) { - const pricingSource = new CodexPricingSource({ offline: options.offline }); + tasks.push( + (async () => { try { - const rows = await buildCodexSessionReport(events, { - pricingSource, - timezone: options.timezone, - locale: options.locale, + const { events, missingDirectories } = await loadTokenUsageEvents({ since: options.since, until: options.until, }); + for (const missing of missingDirectories) { + logger.debug(`Codex session directory not found: ${missing}`); + } - for (const row of rows) { - results.push(normalizeCodexSession(row)); + if (events.length > 0) { + const pricingSource = new CodexPricingSource({ offline: options.offline }); + try { + const rows = await buildCodexSessionReport(events, { + pricingSource, + timezone: options.timezone, + locale: options.locale, + since: options.since, + until: options.until, + }); + + for (const row of rows) { + results.push(normalizeCodexSession(row)); + } + } finally { + pricingSource[Symbol.dispose](); + } } - } finally { - pricingSource[Symbol.dispose](); + } catch (error) { + logger.warn('Failed to load Codex session usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load Codex session usage data.', error); - } + })(), + ); } if (isSourceEnabled('opencode', selectedSources)) { - try { - const [entries, sessionMetadata] = await Promise.all([ - loadOpenCodeMessages({ - since: options.since, - until: options.until, - }), - loadOpenCodeSessions(), - ]); - - if (entries.length > 0) { - using fetcher = new LiteLLMPricingFetcher({ offline: options.offline, logger }); - const rows = await buildOpenCodeSessionReport(entries, { - pricingFetcher: fetcher, - sessionMetadata, - }); - for (const row of rows) { - results.push(normalizeOpenCodeSession(row)); + tasks.push( + (async () => { + try { + const [entries, sessionMetadata] = await Promise.all([ + loadOpenCodeMessages({ + since: options.since, + until: options.until, + }), + loadOpenCodeSessions(), + ]); + + if (entries.length > 0) { + using fetcher = new LiteLLMPricingFetcher({ + offline: options.offline === true, + offlineLoader: options.offline === true ? async () => ({}) : undefined, + logger, + }); + const rows = await buildOpenCodeSessionReport(entries, { + pricingFetcher: fetcher, + sessionMetadata, + }); + for (const row of rows) { + results.push(normalizeOpenCodeSession(row)); + } + } + } catch (error) { + logger.warn('Failed to load OpenCode session usage data.', error); } - } - } catch (error) { - logger.warn('Failed to load OpenCode session usage data.', error); - } + })(), + ); } if (isSourceEnabled('pi', selectedSources)) { - try { - const piData = await loadPiAgentSessionData({ - since: options.since, - until: options.until, - timezone: options.timezone, - order: 'asc', - }); - - for (const entry of piData) { - results.push(normalizePiSession(entry)); - } - } catch (error) { - logger.warn('Failed to load Pi session usage data.', error); - } + tasks.push( + (async () => { + try { + const piData = await loadPiAgentSessionData({ + since: options.since, + until: options.until, + timezone: options.timezone, + order: 'asc', + }); + + for (const entry of piData) { + results.push(normalizePiSession(entry)); + } + } catch (error) { + logger.warn('Failed to load Pi session usage data.', error); + } + })(), + ); } + await Promise.all(tasks); + results.sort((a, b) => { const timeCompare = a.lastTimestamp.localeCompare(b.lastTimestamp); if (timeCompare !== 0) { diff --git a/packages/internal/src/pricing.ts b/packages/internal/src/pricing.ts index 54e97ae9..92b6176a 100644 --- a/packages/internal/src/pricing.ts +++ b/packages/internal/src/pricing.ts @@ -61,6 +61,7 @@ export type LiteLLMPricingFetcherOptions = { offlineLoader?: () => Promise>; url?: string; providerPrefixes?: string[]; + timeoutMs?: number; }; const DEFAULT_PROVIDER_PREFIXES = [ @@ -72,6 +73,7 @@ const DEFAULT_PROVIDER_PREFIXES = [ 'azure/', 'openrouter/openai/', ]; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; function createLogger(logger?: PricingLogger): PricingLogger { if (logger != null) { @@ -93,6 +95,7 @@ export class LiteLLMPricingFetcher implements Disposable { private readonly offlineLoader?: () => Promise>; private readonly url: string; private readonly providerPrefixes: string[]; + private readonly timeoutMs: number; constructor(options: LiteLLMPricingFetcherOptions = {}) { this.logger = createLogger(options.logger); @@ -100,6 +103,7 @@ export class LiteLLMPricingFetcher implements Disposable { this.offlineLoader = options.offlineLoader; this.url = options.url ?? LITELLM_PRICING_URL; this.providerPrefixes = options.providerPrefixes ?? DEFAULT_PROVIDER_PREFIXES; + this.timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; } [Symbol.dispose](): void { @@ -155,7 +159,21 @@ export class LiteLLMPricingFetcher implements Disposable { this.logger.warn('Fetching latest model pricing from LiteLLM...'); return Result.pipe( Result.try({ - try: fetch(this.url), + try: (async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(this.url, { signal: controller.signal }); + } catch (error) { + const message = + error instanceof Error && error.name === 'AbortError' + ? `Timed out fetching pricing after ${this.timeoutMs}ms` + : 'Failed to fetch model pricing from LiteLLM'; + throw new Error(message, { cause: error }); + } finally { + clearTimeout(timeout); + } + })(), catch: (error) => new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), }), diff --git a/packages/terminal/src/table.ts b/packages/terminal/src/table.ts index 7e8f2ddd..d4df3036 100644 --- a/packages/terminal/src/table.ts +++ b/packages/terminal/src/table.ts @@ -211,8 +211,22 @@ export class ResponsiveTable { ), ]; + const modelsIndex = head.findIndex((label) => label.toLowerCase().includes('model')); + const getCellWidth = (value: unknown): number => { + const text = String(value ?? ''); + const lines = text.split('\n'); + let maxWidth = 0; + for (const line of lines) { + const width = stringWidth(line); + if (width > maxWidth) { + maxWidth = width; + } + } + return maxWidth; + }; + const contentWidths = head.map((_, colIndex) => { - const maxLength = Math.max(...allRows.map((row) => stringWidth(String(row[colIndex] ?? '')))); + const maxLength = Math.max(...allRows.map((row) => getCellWidth(row[colIndex]))); return maxLength; }); @@ -227,7 +241,7 @@ export class ResponsiveTable { // For numeric columns, ensure generous width to prevent truncation if (align === 'right') { return Math.max(width + 3, 11); // At least 11 chars for numbers, +3 padding - } else if (index === 1) { + } else if (index === modelsIndex) { // Models column - can be longer return Math.max(width + 2, 15); } @@ -249,7 +263,7 @@ export class ResponsiveTable { adjustedWidth = Math.max(adjustedWidth, 10); } else if (index === 0) { adjustedWidth = Math.max(adjustedWidth, 10); - } else if (index === 1) { + } else if (index === modelsIndex) { adjustedWidth = Math.max(adjustedWidth, 12); } else { adjustedWidth = Math.max(adjustedWidth, 8); From f75af78912366d43e0fa2c3c3fbcefd026629dd9 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 00:18:05 -0700 Subject: [PATCH 08/12] fix: make date filters timezone-aware --- apps/ccusage/src/data-loader.ts | 17 +++++++++++++--- apps/codex/src/data-loader.ts | 9 +++++++-- apps/omni/src/data-aggregator.ts | 3 +++ apps/pi/src/data-loader.ts | 34 +++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index c1f70cd8..daa2e73c 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -716,7 +716,11 @@ export async function globUsageFiles(claudePaths: string[]): Promise { +async function filterFilesBySince( + files: string[], + since?: string, + timezone?: string, +): Promise { if (since == null || since.trim() === '') { return files; } @@ -727,7 +731,10 @@ async function filterFilesBySince(files: string[], since?: string): Promise { try { const fileStat = await stat(file); - const dateKey = new Date(fileStat.mtimeMs).toISOString().slice(0, 10).replace(/-/g, ''); + const dateKey = formatDate(new Date(fileStat.mtimeMs).toISOString(), timezone).replace( + /-/g, + '', + ); return dateKey >= sinceKey ? file : null; } catch { return file; @@ -786,7 +793,11 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise { + if (since == null || since.trim() === '') { + return files; + } + + const sinceKey = normalizeDate(since); + return ( + await Promise.all( + files.map(async (file) => { + try { + const fileStat = await stat(file); + const dateKey = normalizeDate( + formatDate(new Date(fileStat.mtimeMs).toISOString(), timezone), + ); + return dateKey >= sinceKey ? file : null; + } catch { + return file; + } + }), + ) + ).filter((file): file is string => file != null); +} + function isInDateRange(date: string, since?: string, until?: string): boolean { const dateKey = normalizeDate(date); if (since != null && dateKey < normalizeDate(since)) { @@ -159,11 +187,15 @@ export async function loadPiAgentData(options?: LoadOptions): Promise(); const entries: EntryData[] = []; - for (const file of files) { + for (const file of filteredFiles) { const project = extractPiAgentProject(file); const sessionId = extractPiAgentSessionId(file); From 8be5503e3b71408424dc159facb35a8e83d74ef2 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 14:18:58 -0700 Subject: [PATCH 09/12] perf(codex): cache DateTimeFormat instances for date/month keys --- apps/codex/src/date-utils.ts | 59 ++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/codex/src/date-utils.ts b/apps/codex/src/date-utils.ts index 8592c933..abf8ef02 100644 --- a/apps/codex/src/date-utils.ts +++ b/apps/codex/src/date-utils.ts @@ -1,27 +1,66 @@ +const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; +const TIMEZONE_CACHE = new Map(); +const DATE_KEY_FORMATTER_CACHE = new Map(); +const MONTH_KEY_FORMATTER_CACHE = new Map(); + function safeTimeZone(timezone?: string): string { if (timezone == null || timezone.trim() === '') { - return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'; + return DEFAULT_TIMEZONE; + } + + const trimmed = timezone.trim(); + const cached = TIMEZONE_CACHE.get(trimmed); + if (cached != null) { + return cached; } try { // Validate timezone by creating a formatter - Intl.DateTimeFormat('en-US', { timeZone: timezone }); - return timezone; + Intl.DateTimeFormat('en-US', { timeZone: trimmed }); + TIMEZONE_CACHE.set(trimmed, trimmed); + return trimmed; } catch { + TIMEZONE_CACHE.set(trimmed, 'UTC'); return 'UTC'; } } -export function toDateKey(timestamp: string, timezone?: string): string { +function getDateKeyFormatter(timezone?: string): Intl.DateTimeFormat { const tz = safeTimeZone(timezone); - const date = new Date(timestamp); + const cached = DATE_KEY_FORMATTER_CACHE.get(tz); + if (cached != null) { + return cached; + } + const formatter = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: tz, }); - return formatter.format(date); + DATE_KEY_FORMATTER_CACHE.set(tz, formatter); + return formatter; +} + +function getMonthKeyFormatter(timezone?: string): Intl.DateTimeFormat { + const tz = safeTimeZone(timezone); + const cached = MONTH_KEY_FORMATTER_CACHE.get(tz); + if (cached != null) { + return cached; + } + + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + timeZone: tz, + }); + MONTH_KEY_FORMATTER_CACHE.set(tz, formatter); + return formatter; +} + +export function toDateKey(timestamp: string, timezone?: string): string { + const date = new Date(timestamp); + return getDateKeyFormatter(timezone).format(date); } export function normalizeFilterDate(value?: string): string | undefined { @@ -71,14 +110,8 @@ export function formatDisplayDate(dateKey: string, locale?: string, _timezone?: } export function toMonthKey(timestamp: string, timezone?: string): string { - const tz = safeTimeZone(timezone); const date = new Date(timestamp); - const formatter = new Intl.DateTimeFormat('en-CA', { - year: 'numeric', - month: '2-digit', - timeZone: tz, - }); - const [year, month] = formatter.format(date).split('-'); + const [year, month] = getMonthKeyFormatter(timezone).format(date).split('-'); return `${year}-${month}`; } From bb5cf059dcca036f92b7105205ab48a22c3549b3 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 15:19:13 -0700 Subject: [PATCH 10/12] fix(internal): dedupe LiteLLM pricing fetches --- packages/internal/src/pricing.ts | 163 +++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 52 deletions(-) diff --git a/packages/internal/src/pricing.ts b/packages/internal/src/pricing.ts index 92b6176a..0460acfc 100644 --- a/packages/internal/src/pricing.ts +++ b/packages/internal/src/pricing.ts @@ -89,6 +89,8 @@ function createLogger(logger?: PricingLogger): PricingLogger { } export class LiteLLMPricingFetcher implements Disposable { + private static sharedOnlinePricing = new Map>(); + private static sharedOnlineFetches = new Map>>(); private cachedPricing: Map | null = null; private readonly logger: PricingLogger; private readonly offline: boolean; @@ -146,6 +148,114 @@ export class LiteLLMPricingFetcher implements Disposable { ); } + private async fetchOnlinePricing(): Result.ResultAsync, Error> { + this.logger.warn('Fetching latest model pricing from LiteLLM...'); + return Result.pipe( + Result.try({ + try: (async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(this.url, { signal: controller.signal }); + } catch (error) { + const message = + error instanceof Error && error.name === 'AbortError' + ? `Timed out fetching pricing after ${this.timeoutMs}ms` + : 'Failed to fetch model pricing from LiteLLM'; + throw new Error(message, { cause: error }); + } finally { + clearTimeout(timeout); + } + })(), + catch: (error) => new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), + }), + Result.andThrough((response) => { + if (!response.ok) { + return Result.fail(new Error(`Failed to fetch pricing data: ${response.statusText}`)); + } + return Result.succeed(); + }), + Result.andThen(async (response) => + Result.try({ + try: response.json() as Promise>, + catch: (error) => new Error('Failed to parse pricing data', { cause: error }), + }), + ), + Result.map((data) => { + const pricing = new Map(); + for (const [modelName, modelData] of Object.entries(data)) { + if (typeof modelData !== 'object' || modelData == null) { + continue; + } + + const parsed = v.safeParse(liteLLMModelPricingSchema, modelData); + if (!parsed.success) { + continue; + } + + pricing.set(modelName, parsed.output); + } + return pricing; + }), + Result.inspect((pricing) => { + this.logger.info(`Loaded pricing for ${pricing.size} models`); + }), + ); + } + + private async loadSharedOnlinePricing(): Result.ResultAsync< + Map, + Error + > { + const cached = LiteLLMPricingFetcher.sharedOnlinePricing.get(this.url); + if (cached != null) { + this.cachedPricing = cached; + return Result.succeed(cached); + } + + const inFlight = LiteLLMPricingFetcher.sharedOnlineFetches.get(this.url); + if (inFlight != null) { + return Result.try({ + try: (async () => { + const pricing = await inFlight; + this.cachedPricing = pricing; + return pricing; + })(), + catch: (error) => + error instanceof Error + ? error + : new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), + }); + } + + const fetchPromise = (async () => { + try { + const result = await this.fetchOnlinePricing(); + if (Result.isFailure(result)) { + throw result.error; + } + LiteLLMPricingFetcher.sharedOnlinePricing.set(this.url, result.value); + return result.value; + } finally { + LiteLLMPricingFetcher.sharedOnlineFetches.delete(this.url); + } + })(); + + LiteLLMPricingFetcher.sharedOnlineFetches.set(this.url, fetchPromise); + + return Result.try({ + try: (async () => { + const pricing = await fetchPromise; + this.cachedPricing = pricing; + return pricing; + })(), + catch: (error) => + error instanceof Error + ? error + : new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), + }); + } + private async ensurePricingLoaded(): Result.ResultAsync, Error> { return Result.pipe( this.cachedPricing != null @@ -156,59 +266,8 @@ export class LiteLLMPricingFetcher implements Disposable { return this.loadOfflinePricing(); } - this.logger.warn('Fetching latest model pricing from LiteLLM...'); return Result.pipe( - Result.try({ - try: (async () => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - try { - return await fetch(this.url, { signal: controller.signal }); - } catch (error) { - const message = - error instanceof Error && error.name === 'AbortError' - ? `Timed out fetching pricing after ${this.timeoutMs}ms` - : 'Failed to fetch model pricing from LiteLLM'; - throw new Error(message, { cause: error }); - } finally { - clearTimeout(timeout); - } - })(), - catch: (error) => - new Error('Failed to fetch model pricing from LiteLLM', { cause: error }), - }), - Result.andThrough((response) => { - if (!response.ok) { - return Result.fail(new Error(`Failed to fetch pricing data: ${response.statusText}`)); - } - return Result.succeed(); - }), - Result.andThen(async (response) => - Result.try({ - try: response.json() as Promise>, - catch: (error) => new Error('Failed to parse pricing data', { cause: error }), - }), - ), - Result.map((data) => { - const pricing = new Map(); - for (const [modelName, modelData] of Object.entries(data)) { - if (typeof modelData !== 'object' || modelData == null) { - continue; - } - - const parsed = v.safeParse(liteLLMModelPricingSchema, modelData); - if (!parsed.success) { - continue; - } - - pricing.set(modelName, parsed.output); - } - return pricing; - }), - Result.inspect((pricing) => { - this.cachedPricing = pricing; - this.logger.info(`Loaded pricing for ${pricing.size} models`); - }), + this.loadSharedOnlinePricing(), Result.orElse(async (error) => this.handleFallbackToCachedPricing(error)), ); }), From f0c22a9ce77722da3a2517c3aa8c4e34fa936825 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 15:34:24 -0700 Subject: [PATCH 11/12] chore: remove internal planning document --- OMNI_PLAN.md | 814 --------------------------------------------------- 1 file changed, 814 deletions(-) delete mode 100644 OMNI_PLAN.md diff --git a/OMNI_PLAN.md b/OMNI_PLAN.md deleted file mode 100644 index 037c3213..00000000 --- a/OMNI_PLAN.md +++ /dev/null @@ -1,814 +0,0 @@ -# @ccusage/omni Implementation Plan - -> Unified usage tracking across all AI coding assistants - -## Overview - -**Goal:** Create a new `@ccusage/omni` package that aggregates usage data from all existing ccusage CLI tools into a single, unified view. - -**Supported Sources (v1):** -| Source | Package | Data Directory | Env Override | -|--------|---------|----------------|--------------| -| Claude Code | `ccusage` | `~/.claude/projects/` or `~/.config/claude/projects/` | `CLAUDE_CONFIG_DIR` | -| OpenAI Codex | `@ccusage/codex` | `~/.codex/sessions/` | `CODEX_HOME` | -| OpenCode | `@ccusage/opencode` | `~/.local/share/opencode/storage/message/` | `OPENCODE_DATA_DIR` | -| Pi-agent | `@ccusage/pi` | `~/.pi/agent/sessions/` | `PI_AGENT_DIR` | - -> **Note:** Amp (`@ccusage/amp`) is excluded from v1 due to significant schema/semantics divergence (credits-based billing, different totalTokens calculation, different field names). Amp support is planned for a future version. - -**Usage:** - -```bash -npx @ccusage/omni@latest daily # Combined daily report -npx @ccusage/omni@latest monthly # Combined monthly report -npx @ccusage/omni@latest session # Combined session report -``` - ---- - -## Key Design Decisions - -These decisions have been confirmed through review: - -1. **Data Access Strategy:** Add exports to each app (Option A) - - Least disruptive approach - - Requires adding `exports` to each app's `package.json` - - Requires updating `tsdown.config.ts` to build exported files - -2. **Totals Semantics:** Source-faithful (Option A) - - Omni totals match each individual CLI exactly - - Grand total row shows **cost only** (comparable across sources) - - Token totals shown per-source only (not summed across sources with different semantics) - -3. **`--breakdown` Flag:** Omit for v1 (Option C) - - Only Claude and Pi support `--breakdown` - - Show models list in output instead - - Can add `--breakdown` later when all sources support it - -4. **Amp Exclusion:** Removed from v1 scope - - Different billing model (credits vs subscription) - - Different totalTokens semantics (cache excluded) - - Different field names throughout - - Planned for future version - ---- - -## Data Access Architecture - -### Current State of Each App - -| App | Has Daily Loader | Has Report Builder | tsdown Builds | Needs Changes | -| -------- | --------------------------- | ----------------------- | ------------------- | ---------------------------- | -| ccusage | ✅ `loadDailyUsageData()` | Built-in | `./src/*.ts` | None (already exports) | -| codex | ❌ Raw only | ✅ `buildDailyReport()` | `src/index.ts` only | Add exports + tsdown entries | -| opencode | ❌ Raw only | ❌ In-command | `src/index.ts` only | Add report builder + exports | -| pi | ✅ `loadPiAgentDailyData()` | Built-in | `src/index.ts` only | Add exports + tsdown entries | - -### Required Changes Per App - -#### `@ccusage/codex` - -**package.json** - Add exports: - -```json -{ - "exports": { - ".": "./src/index.ts", - "./data-loader": "./src/data-loader.ts", - "./daily-report": "./src/daily-report.ts", - "./monthly-report": "./src/monthly-report.ts", - "./session-report": "./src/session-report.ts", - "./types": "./src/_types.ts", - "./package.json": "./package.json" - }, - "publishConfig": { - "exports": { - ".": "./dist/index.js", - "./data-loader": "./dist/data-loader.js", - "./daily-report": "./dist/daily-report.js", - "./monthly-report": "./dist/monthly-report.js", - "./session-report": "./dist/session-report.js", - "./types": "./dist/_types.js", - "./package.json": "./package.json" - } - } -} -``` - -**tsdown.config.ts** - Add entry points: - -```typescript -entry: [ - 'src/index.ts', - 'src/data-loader.ts', - 'src/daily-report.ts', - 'src/monthly-report.ts', - 'src/session-report.ts', - 'src/_types.ts', -], -``` - -#### `@ccusage/pi` - -**package.json** - Add exports: - -```json -{ - "exports": { - ".": "./src/index.ts", - "./data-loader": "./src/data-loader.ts", - "./package.json": "./package.json" - }, - "publishConfig": { - "exports": { - ".": "./dist/index.js", - "./data-loader": "./dist/data-loader.js", - "./package.json": "./package.json" - } - } -} -``` - -Note: Pi's types (`DailyUsageWithSource`, `SessionUsageWithSource`, `MonthlyUsageWithSource`) are defined and exported from `data-loader.ts`, not a separate types file. - -**tsdown.config.ts** - Add entry points: - -```typescript -entry: [ - 'src/index.ts', - 'src/data-loader.ts', -], -``` - -#### `@ccusage/opencode` - -This app needs **new report builder functions** similar to Codex's pattern. - -**Naming Convention:** - -- Report builders should be named `buildDailyReport`, `buildMonthlyReport`, `buildSessionReport` -- Return types should be `DailyReportRow`, `MonthlyReportRow`, `SessionReportRow` -- Types are exported from the report builder files - -**Required Changes:** - -1. Create `daily-report.ts`, `monthly-report.ts`, `session-report.ts` -2. Extract grouping logic from commands into these files -3. Export report row types from each report builder file -4. Add exports and tsdown entries - -**OpenCode Report Row Types:** - -```typescript -// daily-report.ts -export type DailyReportRow = { - date: string; // YYYY-MM-DD - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; // input + output + cache (additive) - totalCost: number; - modelsUsed: string[]; -}; - -// monthly-report.ts -export type MonthlyReportRow = { - month: string; // YYYY-MM - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; - totalCost: number; - modelsUsed: string[]; -}; - -// session-report.ts -export type SessionReportRow = { - sessionID: string; // Note: uppercase ID (matches current CLI output) - sessionTitle: string; - parentID: string | null; // Note: uppercase ID (matches current CLI output) - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - totalTokens: number; - totalCost: number; - modelsUsed: string[]; - lastActivity: string; // ISO timestamp -}; -``` - -### What Omni Will Import - -```typescript -import type { DailyReportRow as CodexDailyReportRow } from '@ccusage/codex/types'; -import type { DailyReportRow as OpenCodeDailyReportRow } from '@ccusage/opencode/daily-report'; - -import type { DailyUsageWithSource as PiDailyUsage } from '@ccusage/pi/data-loader'; -import type { DailyUsage } from 'ccusage/data-loader'; -import { buildDailyReport as buildCodexDailyReport } from '@ccusage/codex/daily-report'; - -// Codex - after adding exports (types already in _types.ts) -import { loadTokenUsageEvents } from '@ccusage/codex/data-loader'; -// OpenCode - after adding report builders + exports -import { buildDailyReport as buildOpenCodeDailyReport } from '@ccusage/opencode/daily-report'; - -// Pi - after adding exports (types already in data-loader.ts) -import { loadPiAgentDailyData } from '@ccusage/pi/data-loader'; -// Claude - already exports everything -import { loadDailyUsageData } from 'ccusage/data-loader'; -``` - ---- - -## Architecture - -### Directory Structure - -``` -apps/omni/ -├── src/ -│ ├── index.ts # CLI entry point (gunshi) -│ ├── run.ts # CLI runner setup -│ ├── logger.ts # Logger instance -│ ├── _types.ts # Unified type definitions -│ ├── _consts.ts # Constants (source names, colors) -│ ├── _normalizers/ # Per-source data normalizers -│ │ ├── index.ts # Re-exports all normalizers -│ │ ├── claude.ts # Claude Code normalizer -│ │ ├── codex.ts # Codex normalizer (special handling) -│ │ ├── opencode.ts # OpenCode normalizer -│ │ └── pi.ts # Pi-agent normalizer -│ ├── data-aggregator.ts # Main aggregation logic -│ └── commands/ -│ ├── index.ts # Command exports -│ ├── daily.ts # Combined daily report -│ ├── monthly.ts # Combined monthly report -│ └── session.ts # Combined session report -├── package.json -├── tsconfig.json -├── tsdown.config.ts -├── vitest.config.ts -├── eslint.config.js -└── CLAUDE.md -``` - -### Dependencies - -```json -{ - "devDependencies": { - "ccusage": "workspace:*", - "@ccusage/codex": "workspace:*", - "@ccusage/opencode": "workspace:*", - "@ccusage/pi": "workspace:*", - "@ccusage/internal": "workspace:*", - "@ccusage/terminal": "workspace:*", - "@praha/byethrow": "catalog:runtime", - "gunshi": "catalog:runtime", - "picocolors": "catalog:runtime", - "valibot": "catalog:runtime", - "type-fest": "catalog:runtime", - "es-toolkit": "catalog:runtime", - "fast-sort": "catalog:runtime", - "vitest": "catalog:testing", - "fs-fixture": "catalog:testing", - "tsdown": "catalog:build", - "clean-pkg-json": "catalog:release", - "eslint": "catalog:lint", - "@ryoppippi/eslint-config": "catalog:lint", - "@typescript/native-preview": "catalog:types" - } -} -``` - ---- - -## Type Definitions - -### Unified Types (`_types.ts`) - -```typescript -import type { TupleToUnion } from 'type-fest'; - -/** - * Supported data sources (v1) - */ -export const Sources = ['claude', 'codex', 'opencode', 'pi'] as const; -export type Source = TupleToUnion; - -/** - * Unified token usage (normalized across all sources) - * - * IMPORTANT: Token semantics differ by source - totals are SOURCE-FAITHFUL: - * - Claude/OpenCode/Pi: totalTokens = input + output + cacheRead + cacheCreation - * - Codex: totalTokens = input + output (cache is subset of input, NOT additive) - * - * The normalizers preserve each source's native totalTokens calculation. - * Grand totals should show COST ONLY since token semantics are not comparable. - */ -export type UnifiedTokenUsage = { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - totalTokens: number; // Source-faithful, NOT recalculated -}; - -/** - * Unified daily usage entry - */ -export type UnifiedDailyUsage = UnifiedTokenUsage & { - source: Source; - date: string; // YYYY-MM-DD - costUSD: number; - models: string[]; -}; - -/** - * Unified monthly usage entry - */ -export type UnifiedMonthlyUsage = UnifiedTokenUsage & { - source: Source; - month: string; // YYYY-MM - costUSD: number; - models: string[]; -}; - -/** - * Unified session usage entry - */ -export type UnifiedSessionUsage = UnifiedTokenUsage & { - source: Source; - sessionId: string; - displayName: string; // Session name or project path - firstTimestamp: string; - lastTimestamp: string; - costUSD: number; - models: string[]; -}; - -/** - * Aggregated totals by source - */ -export type SourceTotals = { - source: Source; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - totalTokens: number; // Source-faithful - costUSD: number; -}; - -/** - * Combined report totals - * NOTE: Only costUSD is summed across sources. Token totals are per-source only. - */ -export type CombinedTotals = { - costUSD: number; // Sum across all sources (comparable) - bySource: SourceTotals[]; // Per-source breakdown with tokens -}; -``` - -### Field Mapping Reference - -| Unified Field | Claude | Codex | OpenCode | Pi | -| --------------------- | --------------------- | --------------------- | --------------------- | --------------------- | -| `inputTokens` | `inputTokens` | `inputTokens` | `inputTokens` | `inputTokens` | -| `outputTokens` | `outputTokens` | `outputTokens` | `outputTokens` | `outputTokens` | -| `cacheReadTokens` | `cacheReadTokens` | `cachedInputTokens`\* | `cacheReadTokens` | `cacheReadTokens` | -| `cacheCreationTokens` | `cacheCreationTokens` | `0` | `cacheCreationTokens` | `cacheCreationTokens` | -| `totalTokens` | input+output+cache | `totalTokens`\*\* | input+output+cache | input+output+cache | -| `costUSD` | `totalCost` | `costUSD` | `totalCost` | `totalCost` | - -**\* Codex Note:** `cachedInputTokens` is a **subset** of `inputTokens`, not additive. - -**\*\* Codex totalTokens:** `totalTokens = input + output` (cache is subset, not added separately) - ---- - -## Token Normalization Strategy - -### Source-Faithful Approach - -Each normalizer preserves the source's native `totalTokens` calculation: - -**`_normalizers/claude.ts`** - -```typescript -import type { DailyUsage } from 'ccusage/data-loader'; -import type { UnifiedDailyUsage } from '../_types.ts'; - -export function normalizeClaudeDaily(data: DailyUsage): UnifiedDailyUsage { - return { - source: 'claude', - date: data.date, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheReadTokens: data.cacheReadTokens, - cacheCreationTokens: data.cacheCreationTokens, - // Claude includes cache in total - totalTokens: - data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, - costUSD: data.totalCost, - models: data.modelsUsed, - }; -} -``` - -**`_normalizers/codex.ts`** - -```typescript -import type { DailyReportRow } from '@ccusage/codex/types'; -import type { UnifiedDailyUsage } from '../_types.ts'; - -export function normalizeCodexDaily(data: DailyReportRow): UnifiedDailyUsage { - return { - source: 'codex', - date: data.date, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - // Codex: cachedInputTokens is subset of inputTokens - cacheReadTokens: data.cachedInputTokens, - cacheCreationTokens: 0, - // Source-faithful: use Codex's totalTokens directly (input + output) - totalTokens: data.totalTokens, - costUSD: data.costUSD, - models: Object.keys(data.models), - }; -} -``` - -**`_normalizers/opencode.ts`** - -```typescript -import type { DailyReportRow } from '@ccusage/opencode/daily-report'; -import type { UnifiedDailyUsage } from '../_types.ts'; - -export function normalizeOpenCodeDaily(data: DailyReportRow): UnifiedDailyUsage { - return { - source: 'opencode', - date: data.date, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheReadTokens: data.cacheReadTokens, - cacheCreationTokens: data.cacheCreationTokens, - // OpenCode includes cache in total - totalTokens: data.totalTokens, - costUSD: data.totalCost, - models: data.modelsUsed, - }; -} -``` - -**`_normalizers/pi.ts`** - -```typescript -import type { DailyUsageWithSource } from '@ccusage/pi/data-loader'; -import type { UnifiedDailyUsage } from '../_types.ts'; - -export function normalizePiDaily(data: DailyUsageWithSource): UnifiedDailyUsage { - return { - source: 'pi', - date: data.date, - inputTokens: data.inputTokens, - outputTokens: data.outputTokens, - cacheReadTokens: data.cacheReadTokens, - cacheCreationTokens: data.cacheCreationTokens, - // Pi includes cache in total - totalTokens: - data.inputTokens + data.outputTokens + data.cacheReadTokens + data.cacheCreationTokens, - costUSD: data.totalCost, // Pi uses totalCost, not costUSD - models: data.modelsUsed, - }; -} -``` - ---- - -## CLI Interface Design - -### Common Flags - -| Flag | Short | Description | Notes | -| ------------ | ----- | ------------------------------------------ | ---------------------- | -| `--json` | `-j` | Output in JSON format | All sources | -| `--sources` | `-s` | Comma-separated list of sources to include | All sources | -| `--compact` | `-c` | Force compact table mode | All sources | -| `--since` | | Start date filter (YYYY-MM-DD) | Claude, Codex, Pi only | -| `--until` | | End date filter (YYYY-MM-DD) | Claude, Codex, Pi only | -| `--days` | `-d` | Show last N days | Claude, Codex, Pi only | -| `--timezone` | | Timezone for date display | Claude, Codex, Pi only | -| `--locale` | | Locale for number/date formatting | Claude, Codex, Pi only | -| `--offline` | | Use cached pricing data | Claude, Codex only | - -**Notes:** - -- `--breakdown` is intentionally omitted from v1. Models are shown in a column instead. -- `--offline` is passed only to Claude/Codex loaders until other sources support offline pricing. -- `--since`, `--until`, `--days`, `--timezone`, `--locale` are passed only to Claude/Codex/Pi loaders. OpenCode returns all data (filtering can be added in a future version). - -### Example Commands - -```bash -# All sources, daily report -npx @ccusage/omni@latest daily - -# Only Claude and Codex -npx @ccusage/omni@latest daily --sources claude,codex - -# JSON output -npx @ccusage/omni@latest daily --json - -# Last 7 days -npx @ccusage/omni@latest daily --days 7 - -# With date range filter -npx @ccusage/omni@latest daily --since 2026-01-01 --until 2026-01-15 -``` - -### Table Output Design - -**Daily Report:** - -``` -╔══════════════════════════════════════════════════════════════════════════════════════╗ -║ Omni Usage Report - Daily (All Sources) ║ -╚══════════════════════════════════════════════════════════════════════════════════════╝ - -┌──────────┬────────────┬─────────────┬──────────────┬───────────┬──────────┬──────────┐ -│ Source │ Date │ Input │ Output │ Cache │ Cost │ Models │ -├──────────┼────────────┼─────────────┼──────────────┼───────────┼──────────┼──────────┤ -│ Claude │ 2026-01-16 │ 1,234,567 │ 456,789 │ 789,012 │ $12.34 │ sonnet-4 │ -│ Codex │ 2026-01-16 │ 987,654 │ 321,098 │ 654,321† │ $8.76 │ gpt-5 │ -│ OpenCode │ 2026-01-16 │ 543,210 │ 123,456 │ 234,567 │ $5.43 │ sonnet-4 │ -│ Pi │ 2026-01-16 │ 111,111 │ 22,222 │ 33,333 │ $1.50 │ sonnet-4 │ -│ Claude │ 2026-01-15 │ 1,111,111 │ 222,222 │ 333,333 │ $10.00 │ sonnet-4 │ -│ Codex │ 2026-01-15 │ 444,444 │ 555,555 │ 666,666† │ $7.50 │ gpt-5 │ -└──────────┴────────────┴─────────────┴──────────────┴───────────┴──────────┴──────────┘ - -† Codex cache is subset of input (not additive) - -By Source: Cost - • Claude ...................... $22.34 - • Codex ....................... $16.26 - • OpenCode .................... $5.43 - • Pi .......................... $1.50 - ─────── - TOTAL $45.53 -``` - -**Key Design Points:** - -- Token grand totals are NOT shown (different semantics per source) -- Cost grand total IS shown (comparable across sources) -- Per-source breakdown shows individual token totals -- Footnote explains Codex cache semantics - -**Cache Column Definition:** - -- Cache = `cacheReadTokens + cacheCreationTokens` (sum of both) -- For Codex, cache is still shown but marked with † to indicate it's a subset of input (not additive) - -**JSON Output Structure:** - -```json -{ - "daily": [ - { - "source": "claude", - "date": "2026-01-16", - "inputTokens": 1234567, - "outputTokens": 456789, - "cacheReadTokens": 789012, - "cacheCreationTokens": 0, - "totalTokens": 2480368, - "costUSD": 12.34, - "models": ["claude-sonnet-4-20250514"] - }, - { - "source": "codex", - "date": "2026-01-16", - "inputTokens": 987654, - "outputTokens": 321098, - "cacheReadTokens": 654321, - "cacheCreationTokens": 0, - "totalTokens": 1308752, - "costUSD": 8.76, - "models": ["gpt-5"] - } - ], - "totals": { - "costUSD": 45.53, - "bySource": [ - { - "source": "claude", - "inputTokens": 2345678, - "outputTokens": 679011, - "cacheReadTokens": 1122345, - "cacheCreationTokens": 0, - "totalTokens": 4147034, - "costUSD": 22.34 - }, - { - "source": "codex", - "inputTokens": 1432098, - "outputTokens": 876653, - "cacheReadTokens": 1320987, - "cacheCreationTokens": 0, - "totalTokens": 2308751, - "costUSD": 16.26 - }, - { - "source": "opencode", - "inputTokens": 543210, - "outputTokens": 123456, - "cacheReadTokens": 200000, - "cacheCreationTokens": 34567, - "totalTokens": 901233, - "costUSD": 5.43 - }, - { - "source": "pi", - "inputTokens": 111111, - "outputTokens": 22222, - "cacheReadTokens": 33333, - "cacheCreationTokens": 0, - "totalTokens": 166666, - "costUSD": 1.5 - } - ] - } -} -``` - ---- - -## CLI Entry Point - -**`run.ts`** - Following existing Gunshi patterns: - -```typescript -import process from 'node:process'; -import { cli } from 'gunshi'; -import { description, name, version } from '../package.json'; -import { dailyCommand } from './commands/daily.ts'; -import { monthlyCommand } from './commands/monthly.ts'; -import { sessionCommand } from './commands/session.ts'; - -export async function run(): Promise { - const args = process.argv.slice(2); - - // Strip binary name if present (matches existing CLI patterns) - const filteredArgs = args[0] === name ? args.slice(1) : args; - - await cli(filteredArgs, dailyCommand, { - name, - description, - version, - subCommands: { - daily: dailyCommand, - monthly: monthlyCommand, - session: sessionCommand, - }, - }); -} -``` - -**`index.ts`**: - -```typescript -#!/usr/bin/env node -import { run } from './run.ts'; - -await run(); -``` - ---- - -## Testing Strategy - -### Unit Tests (In-Source) - -1. **Normalizer tests** - Verify each normalizer correctly transforms source data - - **Critical: Test source-faithful totalTokens** - Codex uses input+output only -2. **Aggregator tests** - Verify data is properly combined and sorted -3. **Totals calculation tests** - Verify cost totals are summed, token totals are per-source only - -### Test File Structure - -Tests will be in-source using `if (import.meta.vitest != null)` blocks per project convention. - ---- - -## Edge Cases & Error Handling - -| Scenario | Handling | -| ------------------------------ | ---------------------------------------------------- | -| Source has no data | Skip silently, continue with other sources | -| Source directory doesn't exist | Skip silently, log at debug level | -| Source data fails to parse | Skip that source, log warning | -| All sources empty | Display "No usage data found" message | -| Single source requested | Works like running that tool directly | -| Network error (pricing) | Use cached/fallback pricing | -| Codex missing totalTokens | Calculate as `input + output` (per Codex convention) | - ---- - -## Implementation Checklist - -### Phase 0: Prerequisite Changes to Other Apps - -- [ ] **@ccusage/codex** - - [ ] Add exports to `package.json` (include `./types` → `_types.ts`) - - [ ] Update `tsdown.config.ts` entry points (add `_types.ts`) - -- [ ] **@ccusage/pi** - - [ ] Add exports to `package.json` (types are in `data-loader.ts`, not `_types.ts`) - - [ ] Update `tsdown.config.ts` entry points - -- [ ] **@ccusage/opencode** - - [ ] Create `daily-report.ts` (extract from command, export `DailyReportRow` type) - - [ ] Create `monthly-report.ts` (extract from command, export `MonthlyReportRow` type) - - [ ] Create `session-report.ts` (extract from command, export `SessionReportRow` type) - - [ ] Add exports to `package.json` - - [ ] Update `tsdown.config.ts` entry points - -### Phase 1: Omni Scaffolding - -- [ ] Create `apps/omni/` directory structure -- [ ] Create `package.json` with dependencies -- [ ] Create config files (tsconfig, tsdown, vitest, eslint) -- [ ] Create `CLAUDE.md` - -### Phase 2: Core Infrastructure - -- [ ] Create `_types.ts` with unified types (include token semantics docs) -- [ ] Create `_consts.ts` with source colors/labels -- [ ] Create `logger.ts` - -### Phase 3: Normalizers - -- [ ] Create `_normalizers/claude.ts` -- [ ] Create `_normalizers/codex.ts` (source-faithful totals) -- [ ] Create `_normalizers/opencode.ts` -- [ ] Create `_normalizers/pi.ts` -- [ ] Create `_normalizers/index.ts` -- [ ] Add unit tests for each normalizer - -### Phase 4: Data Aggregator - -- [ ] Create `data-aggregator.ts` -- [ ] Implement `loadCombinedDailyData()` -- [ ] Implement `loadCombinedMonthlyData()` -- [ ] Implement `loadCombinedSessionData()` -- [ ] Add unit tests - -### Phase 5: Commands - -- [ ] Create `commands/daily.ts` -- [ ] Create `commands/monthly.ts` -- [ ] Create `commands/session.ts` -- [ ] Create `commands/index.ts` - -### Phase 6: CLI Entry - -- [ ] Create `index.ts` -- [ ] Create `run.ts` (follow existing Gunshi patterns) -- [ ] Test CLI execution - -### Phase 7: Release - -- [ ] Run `pnpm run format` -- [ ] Run `pnpm typecheck` -- [ ] Run `pnpm run test` -- [ ] Build and test locally -- [ ] Submit PR - ---- - -## Future Enhancements (Post v1) - -1. **Amp support** - Add `@ccusage/amp` once schema/semantics alignment is resolved -2. **`--breakdown`** - Add once all sources support per-model breakdowns -3. **`--group-by-date`** - Aggregate all sources per date into single row -4. **Configurable source paths** - Override default directories via flags -5. **Trend analysis** - Compare usage across time periods -6. **Export formats** - CSV, HTML report generation -7. **MCP integration** - Add omni tools to `@ccusage/mcp` - ---- - -## Notes - -- All dependencies should be `devDependencies` (bundled app pattern) -- Follow existing code style (tabs, double quotes, `.ts` imports) -- Use `@praha/byethrow` Result type for error handling -- Use `gunshi` for CLI framework -- Use `@ccusage/terminal` for table rendering -- No `console.log` - use logger instead -- Vitest globals enabled - no imports needed for `describe`, `it`, `expect` -- `type-fest` is already used in ccusage for `TupleToUnion` - follow same pattern From a782ba930a01c206f532ef9bbe51f0fba3e05ce7 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 17 Jan 2026 15:49:40 -0700 Subject: [PATCH 12/12] fix: correct omni normalizers and opencode filters --- apps/omni/src/_normalizers/codex.ts | 27 +++++++++++++++------------ apps/omni/src/_normalizers/pi.ts | 2 +- apps/opencode/src/data-loader.ts | 14 +++++++++++--- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/omni/src/_normalizers/codex.ts b/apps/omni/src/_normalizers/codex.ts index 9e5c3ee4..94550996 100644 --- a/apps/omni/src/_normalizers/codex.ts +++ b/apps/omni/src/_normalizers/codex.ts @@ -2,35 +2,38 @@ import type { DailyReportRow, MonthlyReportRow, SessionReportRow } from '@ccusag import type { UnifiedDailyUsage, UnifiedMonthlyUsage, UnifiedSessionUsage } from '../_types.ts'; export function normalizeCodexDaily(data: DailyReportRow): UnifiedDailyUsage { + const cacheReadTokens = Math.min(data.cachedInputTokens, data.inputTokens); return { source: 'codex', date: data.date, inputTokens: data.inputTokens, outputTokens: data.outputTokens, - cacheReadTokens: data.cachedInputTokens, + cacheReadTokens, cacheCreationTokens: 0, - totalTokens: data.totalTokens, - costUSD: data.costUSD, - models: Object.keys(data.models), + totalTokens: data.totalTokens ?? data.inputTokens + data.outputTokens, + costUSD: data.costUSD ?? 0, + models: Object.keys(data.models ?? {}), }; } export function normalizeCodexMonthly(data: MonthlyReportRow): UnifiedMonthlyUsage { + const cacheReadTokens = Math.min(data.cachedInputTokens, data.inputTokens); return { source: 'codex', month: data.month, inputTokens: data.inputTokens, outputTokens: data.outputTokens, - cacheReadTokens: data.cachedInputTokens, + cacheReadTokens, cacheCreationTokens: 0, - totalTokens: data.totalTokens, - costUSD: data.costUSD, - models: Object.keys(data.models), + totalTokens: data.totalTokens ?? data.inputTokens + data.outputTokens, + costUSD: data.costUSD ?? 0, + models: Object.keys(data.models ?? {}), }; } export function normalizeCodexSession(data: SessionReportRow): UnifiedSessionUsage { const displayName = data.sessionFile.trim() === '' ? data.sessionId : data.sessionFile; + const cacheReadTokens = Math.min(data.cachedInputTokens, data.inputTokens); return { source: 'codex', sessionId: data.sessionId, @@ -39,11 +42,11 @@ export function normalizeCodexSession(data: SessionReportRow): UnifiedSessionUsa lastTimestamp: data.lastActivity, inputTokens: data.inputTokens, outputTokens: data.outputTokens, - cacheReadTokens: data.cachedInputTokens, + cacheReadTokens, cacheCreationTokens: 0, - totalTokens: data.totalTokens, - costUSD: data.costUSD, - models: Object.keys(data.models), + totalTokens: data.totalTokens ?? data.inputTokens + data.outputTokens, + costUSD: data.costUSD ?? 0, + models: Object.keys(data.models ?? {}), }; } diff --git a/apps/omni/src/_normalizers/pi.ts b/apps/omni/src/_normalizers/pi.ts index 80f51ba8..2f1547ff 100644 --- a/apps/omni/src/_normalizers/pi.ts +++ b/apps/omni/src/_normalizers/pi.ts @@ -64,7 +64,7 @@ if (import.meta.vitest != null) { cacheCreationTokens: 3, cacheReadTokens: 2, totalCost: 0.5, - modelsUsed: ['[pi] claude-opus-4-5'], + modelsUsed: ['[pi] claude-opus-4-20250514'], modelBreakdowns: [], } satisfies DailyUsageWithSource; diff --git a/apps/opencode/src/data-loader.ts b/apps/opencode/src/data-loader.ts index d974f44e..ea8615e5 100644 --- a/apps/opencode/src/data-loader.ts +++ b/apps/opencode/src/data-loader.ts @@ -138,7 +138,10 @@ function normalizeDateInput(value?: string): string | undefined { } const compact = trimmed.replace(/-/g, ''); - return /^\d{8}$/.test(compact) ? compact : undefined; + if (!/^\d{8}$/.test(compact)) { + throw new Error(`Invalid date filter: "${value}"`); + } + return compact; } function getDateKeyFromTimestamp(timestampMs: number): string { @@ -344,10 +347,12 @@ export async function loadOpenCodeMessages( } for (const filePath of messageFiles) { + let fileModifiedMs: number | null = null; if (hasDateFilter) { try { const fileStat = await stat(filePath); - const fileDateKey = getDateKeyFromTimestamp(fileStat.mtimeMs); + fileModifiedMs = fileStat.mtimeMs; + const fileDateKey = getDateKeyFromTimestamp(fileModifiedMs); if (!isWithinRange(fileDateKey, since, until)) { continue; } @@ -362,8 +367,11 @@ export async function loadOpenCodeMessages( continue; } - const createdMs = message.time.created ?? Date.now(); if (hasDateFilter) { + const createdMs = message.time.created ?? fileModifiedMs; + if (createdMs == null) { + continue; + } const dateKey = getDateKeyFromTimestamp(createdMs); if (!isWithinRange(dateKey, since, until)) { continue;