diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 8e22aae2..daa2e73c 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,34 @@ 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 = formatDate(new Date(fileStat.mtimeMs).toISOString(), timezone).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,8 +793,11 @@ 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 +833,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 } 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/data-loader.ts b/apps/codex/src/data-loader.ts index ef23a8f5..6765198c 100644 --- a/apps/codex/src/data-loader.ts +++ b/apps/codex/src/data-loader.ts @@ -12,6 +12,7 @@ import { DEFAULT_SESSION_SUBDIR, SESSION_GLOB, } from './_consts.ts'; +import { toDateKey } from './date-utils.ts'; import { logger } from './logger.ts'; type RawUsage = { @@ -177,6 +178,9 @@ function asNonEmptyString(value: unknown): string | undefined { export type LoadOptions = { sessionDirs?: string[]; + since?: string; // YYYY-MM-DD or YYYYMMDD + until?: string; // YYYY-MM-DD or YYYYMMDD + timezone?: string; }; export type LoadResult = { @@ -185,6 +189,21 @@ export type LoadResult = { }; export async function loadTokenUsageEvents(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 +241,21 @@ 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/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}`; } 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..94550996 --- /dev/null +++ b/apps/omni/src/_normalizers/codex.ts @@ -0,0 +1,81 @@ +import type { DailyReportRow, MonthlyReportRow, SessionReportRow } from '@ccusage/codex/types'; +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, + cacheCreationTokens: 0, + 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, + cacheCreationTokens: 0, + 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, + displayName, + firstTimestamp: data.lastActivity, + lastTimestamp: data.lastActivity, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens, + cacheCreationTokens: 0, + totalTokens: data.totalTokens ?? data.inputTokens + data.outputTokens, + costUSD: data.costUSD ?? 0, + 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..2f1547ff --- /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-20250514'], + 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..c8906972 --- /dev/null +++ b/apps/omni/src/data-aggregator.ts @@ -0,0 +1,656 @@ +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); + const tasks: Promise[] = []; + + if (isSourceEnabled('claude', selectedSources)) { + 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)) { + tasks.push( + (async () => { + try { + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + timezone: options.timezone, + }); + 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)) { + 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); + } + })(), + ); + } + + if (isSourceEnabled('pi', selectedSources)) { + 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) { + 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); + const tasks: Promise[] = []; + + if (isSourceEnabled('claude', selectedSources)) { + 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)) { + tasks.push( + (async () => { + try { + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + timezone: options.timezone, + }); + 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)) { + 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); + } + })(), + ); + } + + if (isSourceEnabled('pi', selectedSources)) { + 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) { + 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); + const tasks: Promise[] = []; + + if (isSourceEnabled('claude', selectedSources)) { + 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)) { + tasks.push( + (async () => { + try { + const { events, missingDirectories } = await loadTokenUsageEvents({ + since: options.since, + until: options.until, + timezone: options.timezone, + }); + 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)) { + 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); + } + })(), + ); + } + + if (isSourceEnabled('pi', selectedSources)) { + 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) { + 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/data-loader.ts b/apps/opencode/src/data-loader.ts index 1f3968ad..ea8615e5 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 { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { isDirectorySync } from 'path-type'; @@ -122,6 +122,42 @@ 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, ''); + if (!/^\d{8}$/.test(compact)) { + throw new Error(`Invalid date filter: "${value}"`); + } + return compact; +} + +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 +287,9 @@ export async function loadOpenCodeSessions(): Promise { +export async function loadOpenCodeMessages( + options: OpenCodeMessageLoadOptions = {}, +): Promise { const openCodePath = getOpenCodePath(); if (openCodePath == null) { return []; @@ -267,22 +305,79 @@ export async function loadOpenCodeMessages(): Promise { 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); + const until = normalizeDateInput(options.until); + 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) { + let fileModifiedMs: number | null = null; + if (hasDateFilter) { + try { + const fileStat = await stat(filePath); + fileModifiedMs = fileStat.mtimeMs; + const fileDateKey = getDateKeyFromTimestamp(fileModifiedMs); + if (!isWithinRange(fileDateKey, since, until)) { + continue; + } + } catch { + // Fall back to reading file contents when stat fails. + } + } + const message = await loadOpenCodeMessage(filePath); if (message == null) { continue; } + if (hasDateFilter) { + const createdMs = message.time.created ?? fileModifiedMs; + if (createdMs == null) { + continue; + } + 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; 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/src/data-loader.ts b/apps/pi/src/data-loader.ts index 61f1d42b..9611a346 100644 --- a/apps/pi/src/data-loader.ts +++ b/apps/pi/src/data-loader.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { stat } from 'node:fs/promises'; import readline from 'node:readline'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; @@ -126,6 +127,33 @@ function normalizeDate(value: string): string { return value.replace(/-/g, ''); } +async function filterFilesBySince( + files: string[], + since?: string, + timezone?: string, +): 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); 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/packages/internal/src/pricing.ts b/packages/internal/src/pricing.ts index 54e97ae9..0460acfc 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) { @@ -87,12 +89,15 @@ 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; 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 +105,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 { @@ -142,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 @@ -152,45 +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: fetch(this.url), - 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)), ); }), 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); 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